fix: 子表保存改为就地同步,避免整批换 id 打断外键。

协议样本、生产产出和盘点明细按行更新并保留原 id;补齐存储证明质检预填、离职人员显示名,以及样本采集通知模板字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-28 10:51:53 +08:00
1 parent d66e55af65
commit 5e563a6669
139 files changed
+59986 -49048

No files matched your search

+2
View File
@@ -70,6 +70,7 @@ Full specs: `openspec/changes/zkxw-engineering-standards/specs/*/spec.md`. Below
- **DB changes**: SHALL maintain SQL in `docs/sql/`. Schema changes SHALL align with `database-conventions` public field rules.
- **Commit messages**: SHALL be complete sentences describing "what changed and why". Follow Conventional Commits (`feat|fix|docs|…`).
- **Field consistency (CRUD ↔ Schema ↔ Frontend)**: When implementing CRUD for any entity, the `_row_to_dict` function is the **single source of truth** for all readable fields. The CreateBody/UpdateBody Pydantic schemas and the frontend `formData` initialization SHALL include every writable field present in `_row_to_dict` (excluding computed/read-only fields like `id`, `createTime`, `updateTime`, `stockQty`). Run `python scripts/check_field_consistency.py` after implementation to verify. See `docs/dev-field-consistency.md` for details.
- **Child-row save(禁止整表删除再新增)**: 保存子表时 SHALL 按行处理:已有行就地更新,只插入新增行,只软删除用户去掉的行。客户端可以提交稳定 `id`,字段变化时该行必须保留原 `id`。请求未带 `id` 时,先用稳定业务键(样本编码、物料编码、条码、组分 id 等)匹配一条尚未占用的有效行,匹配不到再插入新 UUID。属于其他父记录或其他租户的 `id` SHALL 拒绝。SHALL NOT 在保存时把子表全部软删除再整批插入新行——其他表会拿这些 id 做外键(`production_step_output_id`、`protocol_sample_id`、`donation_sample_id`),整批换 id 会把关联打断。删除父记录时可以级联软删除其子行。复制或克隆父记录时 SHALL 为子行分配新 id。实现入口:`backend/app/crud/child_row_sync.py` 的 `sync_child_rows`。
### Permission Conventions
@@ -220,4 +221,5 @@ pnpm clean:dev # strip demo data for fresh start
- **Table column widths**: All `el-table-column` SHALL use `min-width` (not `width`) for proportional scaling when viewport resizes.
- **Toolbar**: "已选 N 项" first, then primary action button (only one `type="primary"` per toolbar row), no `space-between` push.
- **Database changes**: always pair with schema SQL in `docs/sql/` and evaluate API/TypeScript type impact.
- **Child-row save**: SHALL NOT soft-delete every child row and insert new rows on save. Update in place, insert new rows, soft-delete only removed rows. See Coding Conventions.
- **进程重启**:修改 bug 时,如果前端或后端需要重启,Agent 只告知用户,由用户自行重启。Agent 不得自行停止、启动或重启 `web-admin` 开发服务与 `backend` 服务。
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1
View File
@@ -171,6 +171,7 @@ class StockCheckCreateBody(BaseModel):
class StockCheckUnusedLineItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
usage_id: Optional[str] = Field(None, alias="usageId")
batch_id: Optional[str] = Field(None, alias="batchId")
material_code: Optional[str] = Field(None, alias="materialCode")
+11 -2
View File
@@ -1277,6 +1277,7 @@ async def delete_consumable_pack(
class ProcessReagentInput(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
reagent_code: Optional[str] = Field(None, alias="reagentCode")
reagent_name: Optional[str] = Field(None, alias="reagentName")
spec: Optional[str] = None
@@ -1288,6 +1289,7 @@ class ProcessReagentInput(BaseModel):
class ProcessConsumableInput(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
consumable_code: Optional[str] = Field(None, alias="consumableCode")
consumable_name: Optional[str] = Field(None, alias="consumableName")
spec_model: Optional[str] = Field(None, alias="specModel")
@@ -1299,6 +1301,7 @@ class ProcessConsumableInput(BaseModel):
class ProcessOutputInput(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
output_type_id: Optional[str] = Field(None, alias="outputTypeId")
output_name: Optional[str] = Field(None, alias="outputName")
output_barcode: Optional[str] = Field(None, alias="outputBarcode")
@@ -1307,6 +1310,7 @@ class ProcessOutputInput(BaseModel):
class ProcessDataCollectionInput(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
collection_name: str = Field(..., alias="collectionName")
unit: Optional[str] = None
order_num: Optional[int] = Field(0, alias="orderNum")
@@ -2734,6 +2738,7 @@ async def replace_task_step_reagents(
for it in body:
d = it.model_dump(by_alias=False)
items_data.append({
"id": d.get("id"),
"reagent_code": d.get("material_code"),
"reagent_name": d.get("material_name"),
"spec": d.get("spec"),
@@ -2767,6 +2772,7 @@ async def replace_task_step_consumables(
for it in body:
d = it.model_dump(by_alias=False)
items_data.append({
"id": d.get("id"),
"consumable_code": d.get("material_code"),
"consumable_name": d.get("material_name"),
"spec_model": d.get("spec_model"),
@@ -2800,6 +2806,7 @@ async def replace_task_step_configured_reagents(
for it in body:
d = it.model_dump(by_alias=False)
items_data.append({
"id": d.get("id"),
"configured_reagent_code": d.get("material_code"),
"configured_reagent_name": d.get("material_name"),
"spec": d.get("spec"),
@@ -2851,8 +2858,10 @@ async def replace_task_step_outputs(
try:
tenant_no = _get_tenant_no(db, user)
items_data = [it.model_dump(by_alias=False) for it in body]
production_task_svc.replace_step_outputs(db, step_id, tenant_no, user.get("user_id"), items_data)
return success_response(None, message="更新成功")
saved = production_task_svc.replace_step_outputs(
db, step_id, tenant_no, user.get("user_id"), items_data
)
return success_response(saved, message="更新成功")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
+2
View File
@@ -739,6 +739,7 @@ class StockCheckCreateBody(BaseModel):
class StockCheckUnusedLineItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
usage_id: Optional[str] = Field(None, alias="usageId")
batch_id: Optional[str] = Field(None, alias="batchId")
material_code: Optional[str] = Field(None, alias="materialCode")
@@ -926,6 +927,7 @@ async def cancel_production_material_stock_check(
class FormulaLineBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
component_material_id: str = Field(..., alias="componentMaterialId")
component_material_type: str = Field(..., alias="componentMaterialType")
standard_qty: float = Field(..., gt=0, alias="standardQty")
+1
View File
@@ -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")
+4 -2
View File
@@ -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"
+257
View File
@@ -0,0 +1,257 @@
"""按行同步子表。
带 ID 的行原地修改,没有 ID 时按业务键对上尚未占用的原行,其余才新增。
本次提交里没有的活动行才软删除。不要整表软删后再插入,否则外键会指向已删除的行。
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
def _item_id(item: Dict[str, Any]) -> str:
return str(item.get("id") or "").strip()
def _sort_key(row: Any):
order_num = getattr(row, "order_num", None)
create_time = getattr(row, "create_time", None)
return (
order_num if order_num is not None else 0,
create_time or datetime.min,
str(getattr(row, "id", "") or ""),
)
def sync_child_rows(
db: Session,
model,
*,
parent_attr: str,
parent_id: str,
items: List[Dict[str, Any]],
build_row: Callable[[str], Any],
apply_fields: Callable[[Any, Dict[str, Any], int, datetime], None],
tenant_no: Optional[str] = None,
tenant_attr: str = "tenant_no",
user_id: Optional[str] = None,
now: Optional[datetime] = None,
natural_key: Optional[Callable[[Dict[str, Any]], str]] = None,
row_natural_key: Optional[Callable[[Any], str]] = None,
duplicate_message: str = "编号重复",
foreign_message: str = "记录不属于当前数据",
extra_filters: Optional[Dict[str, Any]] = None,
reuse_deleted: bool = False,
) -> List[Any]:
now = now or datetime.now()
payload = list(items or [])
seen_ids = set()
for item in payload:
row_id = _item_id(item)
if not row_id:
continue
if row_id in seen_ids:
raise ValueError(duplicate_message)
seen_ids.add(row_id)
def _scoped(query):
query = query.filter(getattr(model, parent_attr) == parent_id)
if tenant_no is not None and hasattr(model, tenant_attr):
query = query.filter(getattr(model, tenant_attr) == tenant_no)
for key, value in (extra_filters or {}).items():
query = query.filter(getattr(model, key) == value)
return query
active_rows = _scoped(db.query(model).filter(model.del_flag == 0)).all()
active_by_id = {row.id: row for row in active_rows}
for row_id in seen_ids:
if row_id in active_by_id:
continue
other = db.query(model).filter(model.id == row_id).first()
if other is None:
continue
other_parent = getattr(other, parent_attr)
other_tenant = getattr(other, tenant_attr, None) if tenant_no is not None else tenant_no
if other_parent != parent_id or (
tenant_no is not None and (other_tenant or "") != tenant_no
):
raise ValueError(foreign_message)
queues: Dict[str, List[Any]] = {}
deleted_queues: Dict[str, List[Any]] = {}
if natural_key is not None and row_natural_key is not None:
for row in sorted(active_rows, key=_sort_key):
key = (row_natural_key(row) or "").strip()
if not key:
continue
queues.setdefault(key, []).append(row)
if reuse_deleted:
deleted_rows = _scoped(db.query(model).filter(model.del_flag == 1)).all()
for row in sorted(deleted_rows, key=_sort_key):
key = (row_natural_key(row) or "").strip()
if not key:
continue
deleted_queues.setdefault(key, []).append(row)
claimed = set()
assigned: List[Optional[Any]] = [None] * len(payload)
def _take(row: Any) -> Any:
if row.id in claimed:
raise ValueError(duplicate_message)
claimed.add(row.id)
return row
for idx, item in enumerate(payload):
row_id = _item_id(item)
if not row_id:
continue
if row_id in active_by_id:
assigned[idx] = _take(active_by_id[row_id])
continue
row = db.query(model).filter(model.id == row_id).first()
if row is None:
row = build_row(row_id)
db.add(row)
assigned[idx] = _take(row)
for bucket in queues.values():
bucket[:] = [row for row in bucket if row.id not in claimed]
for idx, item in enumerate(payload):
if assigned[idx] is not None:
continue
row = None
if natural_key is not None:
key = (natural_key(item) or "").strip()
bucket = queues.get(key) if key else None
while bucket:
candidate = bucket.pop(0)
if candidate.id not in claimed:
row = candidate
break
if row is None and reuse_deleted and natural_key is not None:
key = (natural_key(item) or "").strip()
bucket = deleted_queues.get(key) if key else None
while bucket:
candidate = bucket.pop(0)
if candidate.id not in claimed:
row = candidate
break
if row is None:
row = build_row(generate_uuid())
db.add(row)
assigned[idx] = _take(row)
result: List[Any] = []
for idx, item in enumerate(payload):
row = assigned[idx]
setattr(row, parent_attr, parent_id)
if tenant_no is not None and hasattr(row, tenant_attr):
setattr(row, tenant_attr, tenant_no)
apply_fields(row, item, idx, now)
row.del_flag = 0
if hasattr(row, "update_by"):
row.update_by = user_id
if hasattr(row, "update_time"):
row.update_time = now
db.add(row)
result.append(row)
if reuse_deleted and row_natural_key is not None:
keys_to_clear = set()
for row in result:
keys_to_clear.add((row_natural_key(row) or "").strip())
for row in active_rows:
if row.id not in claimed:
keys_to_clear.add((row_natural_key(row) or "").strip())
removed_tombstone = False
for key in keys_to_clear:
if not key:
continue
for tomb in deleted_queues.get(key, []):
if tomb.id in claimed:
continue
db.delete(tomb)
removed_tombstone = True
if removed_tombstone:
db.flush()
for row in active_rows:
if row.id in claimed:
continue
row.del_flag = 1
if hasattr(row, "update_by"):
row.update_by = user_id
if hasattr(row, "update_time"):
row.update_time = now
db.add(row)
return result
def sync_link_rows(
db: Session,
model,
*,
parent_attr: str,
parent_id: str,
link_attr: str,
link_ids: List[str],
tenant_no: Optional[str] = None,
user_id: Optional[str] = None,
duplicate_message: str = "关联重复",
foreign_message: str = "关联不属于当前数据",
) -> List[Any]:
"""按关联 id 更新关系行。重新加回已删除的关联时恢复原行,避免唯一索引冲突。"""
now = datetime.now()
ordered: List[Dict[str, Any]] = []
seen = set()
for raw in link_ids or []:
link_id = str(raw or "").strip()
if not link_id or link_id in seen:
continue
seen.add(link_id)
ordered.append({link_attr: link_id})
def build_row(row_id: str):
data = {
"id": row_id,
parent_attr: parent_id,
link_attr: "",
"create_by": user_id,
"create_dept_id": "",
"create_time": now,
}
if tenant_no is not None and hasattr(model, "tenant_no"):
data["tenant_no"] = tenant_no
return model(**data)
def apply_row(row: Any, item: Dict[str, Any], idx: int, _now: datetime) -> None:
setattr(row, link_attr, item[link_attr])
if tenant_no is not None and hasattr(row, "tenant_no"):
row.tenant_no = tenant_no
if hasattr(row, "order_num"):
row.order_num = idx
return sync_child_rows(
db,
model,
parent_attr=parent_attr,
parent_id=parent_id,
tenant_no=tenant_no,
items=ordered,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda item: item[link_attr],
row_natural_key=lambda row: str(getattr(row, link_attr) or "").strip(),
duplicate_message=duplicate_message,
foreign_message=foreign_message,
reuse_deleted=True,
)
+258 -101
View File
@@ -12,6 +12,7 @@ from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid, parse_datetime, format_date, optional_date
from app.crud.child_row_sync import sync_child_rows
from app.crud.base import camel_to_snake
from app.crud.biz_no_generator import allocate_next_biz_no
from app.crud.protocol_donation_convert import (
@@ -45,8 +46,11 @@ from app.models.models import (
BizProtocolPayment,
BizProtocolPaymentDetail,
BizProtocolSample,
BizProtocolSamplePayment,
BizProtocolSampleTransport,
BizProtocolStorageCert,
BizProductionWorkOrder,
BizQcSmsNotification,
BizQcWorkOrder,
BizSampleReception,
BizStorageReleaseReview,
@@ -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:
+2 -2
View File
@@ -24,7 +24,7 @@ def _resolve_issued_user_display_name(
uid = (user_id or "").strip()
if not uid:
return None
u = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first()
u = db.query(SysUser).filter(SysUser.id == uid).first()
if not u:
return None
return (u.nick_name or u.user_no or "").strip() or None
@@ -41,7 +41,7 @@ def enrich_issued_user_names(db: Session, rows: List[Dict[str, Any]]) -> None:
return
users = (
db.query(SysUser.id, SysUser.nick_name, SysUser.user_no)
.filter(SysUser.id.in_(missing_uids), SysUser.del_flag == 0)
.filter(SysUser.id.in_(missing_uids))
.all()
)
name_map = {(u.id or ""): ((u.nick_name or u.user_no or "").strip()) for u in users}
+32 -23
View File
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.models.models import BizConsumablePack, BizConsumablePackItem
@@ -230,37 +231,45 @@ def update_consumable_pack(
db.add(obj)
if items is not None:
existing = (
db.query(BizConsumablePackItem)
.filter(and_(BizConsumablePackItem.pack_id == pack_id, BizConsumablePackItem.del_flag == 0))
.all()
)
now = datetime.now()
for ei in existing:
ei.del_flag = 1
ei.update_time = now
db.add(ei)
for it in items:
item_obj = BizConsumablePackItem(
id=generate_uuid(),
def build_item(row_id):
return BizConsumablePackItem(
id=row_id,
pack_id=pack_id,
consumable_code=(it.get("consumable_code") or "").strip(),
consumable_name=(it.get("consumable_name") or "").strip(),
spec_model=(it.get("spec_model") or "").strip() or None,
standard_usage=(it.get("standard_usage") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
remark=(it.get("remark") or "").strip() or None,
consumable_code="",
consumable_name="",
tenant_no=obj.tenant_no,
order_num=0,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(item_obj)
def apply_item(row, it, _idx, _now):
row.consumable_code = (it.get("consumable_code") or "").strip()
row.consumable_name = (it.get("consumable_name") or "").strip()
row.spec_model = (it.get("spec_model") or "").strip() or None
row.standard_usage = (it.get("standard_usage") or "").strip() or None
row.unit = (it.get("unit") or "").strip() or None
row.remark = (it.get("remark") or "").strip() or None
row.order_num = 0
sync_child_rows(
db,
BizConsumablePackItem,
parent_attr="pack_id",
parent_id=pack_id,
tenant_no=obj.tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_item,
apply_fields=apply_item,
natural_key=lambda it: (it.get("consumable_code") or "").strip(),
row_natural_key=lambda row: (row.consumable_code or "").strip(),
duplicate_message="耗材包明细重复",
foreign_message="耗材包明细不属于当前耗材包",
)
db.commit()
db.refresh(obj)
+82 -54
View File
@@ -9,6 +9,7 @@ from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.crud.material_stock_batch import _dec, _fmt_date, _fmt_dt, _parse_date, _require_str
from app.models.models import SysUser
from app.models.material import (
@@ -442,6 +443,44 @@ def _resolve_unused_item_fields(
return out
def _unused_line_key(usage_id: Optional[str], batch_id: Optional[str]) -> str:
usage = (usage_id or "").strip()
if usage:
return f"usage:{usage}"
batch = (batch_id or "").strip()
if batch:
return f"batch:{batch}"
return ""
def _prepare_unused_item(db: Session, it: Dict[str, Any], tenant_no: str) -> Dict[str, Any]:
it = _resolve_unused_item_fields(db, it, tenant_no)
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
if qty is None or Decimal(str(qty)) <= 0:
raise ValueError("未用完数量必须大于0")
unit_price = it.get("unit_price") if it.get("unit_price") is not None else it.get("unitPrice")
return {
"id": str(it.get("id") or "").strip(),
"usage_id": (it.get("usage_id") or it.get("usageId") or "").strip() or None,
"batch_id": (it.get("batch_id") or it.get("batchId") or "").strip() or None,
"material_code": (it.get("material_code") or it.get("materialCode") or "").strip() or None,
"material_name": name,
"spec_model": (it.get("spec_model") or it.get("specModel") or "").strip() or None,
"unit": (it.get("unit") or "").strip() or None,
"stock_in_no": (it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
"stock_in_date": _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate")),
"expire_date": _parse_optional_date(it.get("expire_date") or it.get("expireDate")),
"catalog_no": (it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
"batch_no": (it.get("batch_no") or it.get("batchNo") or "").strip() or None,
"manufacturer": (it.get("manufacturer") or "").strip() or None,
"unit_price": Decimal(str(unit_price)) if unit_price is not None else None,
"holder": (it.get("holder") or "").strip() or None,
"unused_qty": Decimal(str(qty)),
"remark": (it.get("remark") or "").strip() or None,
}
def save_unused_lines(
db: Session,
check_id: str,
@@ -458,72 +497,61 @@ def save_unused_lines(
raise ValueError("仅进行中的盘点单可登记未用完物料")
now = datetime.now()
old_rows = (
db.query(BizMaterialStockCheckUnusedLine)
.filter(
BizMaterialStockCheckUnusedLine.check_id == check_id,
BizMaterialStockCheckUnusedLine.del_flag == 0,
)
.all()
)
for r in old_rows:
r.del_flag = 1
r.update_by = user_id
r.update_time = now
db.add(r)
prepared = [_prepare_unused_item(db, it, tenant_no) for it in (items or [])]
count = 0
for it in items or []:
it = _resolve_unused_item_fields(db, it, tenant_no)
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
if qty is None or Decimal(str(qty)) <= 0:
raise ValueError("未用完数量必须大于0")
qty_dec = Decimal(str(qty))
stock_in_date = _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate"))
expire_date = _parse_optional_date(it.get("expire_date") or it.get("expireDate"))
usage_id = (it.get("usage_id") or it.get("usageId") or "").strip() or None
batch_id = (it.get("batch_id") or it.get("batchId") or "").strip() or None
row = BizMaterialStockCheckUnusedLine(
id=generate_uuid(),
def build_row(row_id: str):
return BizMaterialStockCheckUnusedLine(
id=row_id,
check_id=check_id,
usage_id=usage_id,
batch_id=batch_id,
material_code=(it.get("material_code") or it.get("materialCode") or "").strip() or None,
material_name=name,
spec_model=(it.get("spec_model") or it.get("specModel") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
stock_in_no=(it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
stock_in_date=stock_in_date,
expire_date=expire_date,
catalog_no=(it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
batch_no=(it.get("batch_no") or it.get("batchNo") or "").strip() or None,
manufacturer=(it.get("manufacturer") or "").strip() or None,
unit_price=(
Decimal(str(it.get("unit_price") or it.get("unitPrice")))
if (it.get("unit_price") is not None or it.get("unitPrice") is not None)
else None
),
holder=(it.get("holder") or "").strip() or None,
unused_qty=qty_dec,
material_name="",
unused_qty=0,
tenant_no=tenant_no,
remark=(it.get("remark") or "").strip() or None,
create_by=user_id,
create_dept_id=dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(row)
count += 1
def apply_row(row, it, idx, _now):
row.usage_id = it["usage_id"]
row.batch_id = it["batch_id"]
row.material_code = it["material_code"]
row.material_name = it["material_name"]
row.spec_model = it["spec_model"]
row.unit = it["unit"]
row.stock_in_no = it["stock_in_no"]
row.stock_in_date = it["stock_in_date"]
row.expire_date = it["expire_date"]
row.catalog_no = it["catalog_no"]
row.batch_no = it["batch_no"]
row.manufacturer = it["manufacturer"]
row.unit_price = it["unit_price"]
row.holder = it["holder"]
row.unused_qty = it["unused_qty"]
row.remark = it["remark"]
row.order_num = idx
saved = sync_child_rows(
db,
BizMaterialStockCheckUnusedLine,
parent_attr="check_id",
parent_id=check_id,
tenant_no=tenant_no,
items=prepared,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: _unused_line_key(it.get("usage_id"), it.get("batch_id")),
row_natural_key=lambda row: _unused_line_key(row.usage_id, row.batch_id),
duplicate_message="未用完明细重复",
foreign_message="未用完明细不属于当前盘点单",
)
check.update_by = user_id
check.update_time = now
db.add(check)
db.commit()
return count
return len(saved)
def complete_check(
+49 -27
View File
@@ -9,6 +9,7 @@ from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.crud import cell_product as cell_product_svc
from app.models.models import (
BizCellProduct,
@@ -396,13 +397,6 @@ def save_outbound_material(
db, schedule_id, tenant_no, incoming_qc_ids, user_id, now
)
db.query(BizOutboundReleasePaper).filter(
BizOutboundReleasePaper.infusion_schedule_id == schedule_id,
BizOutboundReleasePaper.tenant_no == tenant_no,
BizOutboundReleasePaper.del_flag == 0,
).update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False)
db.flush()
for idx, item in enumerate(cells or []):
cp_id = (item.get("cellProductId") or item.get("cell_product_id") or "").strip()
if not cp_id:
@@ -532,7 +526,8 @@ def save_outbound_material(
)
)
for idx, item in enumerate(paper_materials or []):
paper_payload = []
for item in paper_materials or []:
paper_name = (item.get("paperName") or item.get("paper_name") or "").strip()
attachment_url = (item.get("attachmentUrl") or item.get("attachment_url") or "").strip()
copies_raw = item.get("copies")
@@ -546,26 +541,53 @@ def save_outbound_material(
raise HTTPException(status_code=400, detail="纸质材料份数格式无效")
if copies <= 0:
raise HTTPException(status_code=400, detail="纸质材料份数必须大于0")
db.add(
BizOutboundReleasePaper(
id=generate_uuid(),
material_id=header.id,
infusion_schedule_id=schedule_id,
paper_name=paper_name,
copies=copies,
attachment_url=attachment_url,
tenant_no=tenant_no,
order_num=idx + 1,
remark=(item.get("remark") or "").strip() or None,
create_by=user_id,
create_dept_id=create_dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
paper_payload.append(
{
"paper_name": paper_name,
"copies": copies,
"attachment_url": attachment_url,
"remark": (item.get("remark") or "").strip() or None,
}
)
def build_paper(row_id):
return BizOutboundReleasePaper(
id=row_id,
material_id=header.id,
infusion_schedule_id=schedule_id,
paper_name="",
copies=1,
attachment_url="",
tenant_no=tenant_no,
create_by=user_id,
create_dept_id=create_dept_id or "",
create_time=now,
)
def apply_paper(row, item, idx, _now):
row.material_id = header.id
row.paper_name = item["paper_name"]
row.copies = item["copies"]
row.attachment_url = item["attachment_url"]
row.order_num = idx + 1
row.remark = item["remark"]
sync_child_rows(
db,
BizOutboundReleasePaper,
parent_attr="infusion_schedule_id",
parent_id=schedule_id,
tenant_no=tenant_no,
items=paper_payload,
user_id=user_id,
now=now,
build_row=build_paper,
apply_fields=apply_paper,
natural_key=lambda item: item["attachment_url"],
row_natural_key=lambda row: row.attachment_url or "",
duplicate_message="纸质材料重复",
foreign_message="纸质材料不属于当前输注计划",
)
db.flush()
return get_outbound_material(db, schedule_id, tenant_no)
+67 -58
View File
@@ -8,6 +8,9 @@ from typing import Any, Dict, List, Optional
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.models.models import (
BizCheckupInstitution,
BizPilotApplication,
@@ -429,6 +432,52 @@ def list_reserve_items(
return [_row_to_dict_reserve_item(r) for r in rows]
def _checkup_project_name(item: Dict[str, Any]) -> str:
return (item.get("projectName") or item.get("project_name") or "").strip()
def _checkup_project_fee(item: Dict[str, Any]):
if item.get("projectFee") is not None:
return item.get("projectFee")
return item.get("project_fee")
def _sync_checkup_items(db, model, checkup_id, items_json, tenant_no, user_id, now):
def build_row(row_id):
return model(
id=row_id,
pilot_checkup_id=checkup_id,
project_name="",
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def apply_row(row, item, idx, _now):
row.project_name = _checkup_project_name(item)
row.project_fee = _checkup_project_fee(item)
row.order_num = idx + 1
row.remark = item.get("remark") or ""
sync_child_rows(
db,
model,
parent_attr="pilot_checkup_id",
parent_id=checkup_id,
tenant_no=tenant_no,
items=list(items_json or []),
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=_checkup_project_name,
row_natural_key=lambda row: (row.project_name or "").strip(),
duplicate_message="体检项目重复",
foreign_message="体检项目不属于当前体检单",
)
def save_reserve_items(
db: Session,
checkup_id: str,
@@ -437,35 +486,15 @@ def save_reserve_items(
user_id: str,
) -> List[Dict[str, Any]]:
now = datetime.now()
# Soft-delete existing items
db.query(BizPilotCheckupReserveItem).filter(
BizPilotCheckupReserveItem.pilot_checkup_id == checkup_id,
BizPilotCheckupReserveItem.tenant_no == tenant_no,
BizPilotCheckupReserveItem.del_flag == 0,
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
# Insert new items
for idx, item in enumerate(items_json):
row = BizPilotCheckupReserveItem(
id=uuid.uuid4().hex,
pilot_checkup_id=checkup_id,
project_name=item.get("projectName") or item.get("project_name") or "",
project_fee=item.get("projectFee") if item.get("projectFee") is not None else (
item.get("project_fee") if item.get("project_fee") is not None else None
),
tenant_no=tenant_no,
order_num=idx + 1,
remark=item.get("remark") or "",
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(row)
_sync_checkup_items(
db,
BizPilotCheckupReserveItem,
checkup_id,
items_json,
tenant_no,
user_id,
now,
)
db.commit()
return list_reserve_items(db, checkup_id, tenant_no)
@@ -520,35 +549,15 @@ def save_actual_items(
user_id: str,
) -> List[Dict[str, Any]]:
now = datetime.now()
# Soft-delete existing items
db.query(BizPilotCheckupActualItem).filter(
BizPilotCheckupActualItem.pilot_checkup_id == checkup_id,
BizPilotCheckupActualItem.tenant_no == tenant_no,
BizPilotCheckupActualItem.del_flag == 0,
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
# Insert new items
for idx, item in enumerate(items_json):
row = BizPilotCheckupActualItem(
id=uuid.uuid4().hex,
pilot_checkup_id=checkup_id,
project_name=item.get("projectName") or item.get("project_name") or "",
project_fee=item.get("projectFee") if item.get("projectFee") is not None else (
item.get("project_fee") if item.get("project_fee") is not None else None
),
tenant_no=tenant_no,
order_num=idx + 1,
remark=item.get("remark") or "",
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(row)
_sync_checkup_items(
db,
BizPilotCheckupActualItem,
checkup_id,
items_json,
tenant_no,
user_id,
now,
)
db.commit()
return list_actual_items(db, checkup_id, tenant_no)
+30 -24
View File
@@ -8,6 +8,8 @@ from typing import Any, Dict, List, Optional
from sqlalchemy import and_, func
from sqlalchemy.orm import Session
from app.crud.child_row_sync import sync_child_rows
from app.models.models import (
BizChannelAccount,
BizChannelAgreementProductPrice,
@@ -539,7 +541,7 @@ def save_delivery_materials(
tenant_no: str,
user_id: str,
) -> List[Dict[str, Any]]:
"""Bulk save: soft-delete existing + insert new."""
"""按物料名称更新已有行,只插入新物料,只软删去掉的物料。"""
now = datetime.now()
# Validate delivery exists
@@ -555,36 +557,40 @@ def save_delivery_materials(
if not delivery:
raise ValueError("交付信息不存在")
# Soft-delete existing materials
db.query(BizPilotDeliveryMaterial).filter(
BizPilotDeliveryMaterial.delivery_id == delivery_id,
BizPilotDeliveryMaterial.tenant_no == tenant_no,
BizPilotDeliveryMaterial.del_flag == 0,
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
db.flush()
# Insert new
created = []
for idx, item in enumerate(items_json):
material = BizPilotDeliveryMaterial(
id=str(uuid.uuid4()),
def build_row(row_id):
return BizPilotDeliveryMaterial(
id=row_id,
delivery_id=delivery_id,
material_name=item.get("material_name") or "",
qty=item.get("qty"),
unit_id=item.get("unit_id") or "",
material_name="",
tenant_no=tenant_no,
order_num=idx + 1,
remark=item.get("remark") or "",
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(material)
created.append(material)
def apply_row(row, item, idx, _now):
row.material_name = item.get("material_name") or ""
row.qty = item.get("qty")
row.unit_id = item.get("unit_id") or ""
row.order_num = idx + 1
row.remark = item.get("remark") or ""
created = sync_child_rows(
db,
BizPilotDeliveryMaterial,
parent_attr="delivery_id",
parent_id=delivery_id,
tenant_no=tenant_no,
items=list(items_json or []),
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda item: (item.get("material_name") or "").strip(),
row_natural_key=lambda row: (row.material_name or "").strip(),
duplicate_message="交付物料重复",
foreign_message="交付物料不属于当前交付单",
)
db.commit()
for m in created:
db.refresh(m)
+1 -1
View File
@@ -129,7 +129,7 @@ def get_confirm_by_schedule(
if uid:
user = (
db.query(SysUser)
.filter(SysUser.id == uid, SysUser.del_flag == 0)
.filter(SysUser.id == uid)
.first()
)
data["confirmUserName"] = (user.nick_name or user.user_no or "") if user else ""
@@ -2,7 +2,6 @@
from __future__ import annotations
import json
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
@@ -10,6 +9,7 @@ from sqlalchemy import and_, desc, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.models.models import (
BizChannelAgreementProductPrice,
BizChannelServiceOrder,
@@ -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)
+91 -58
View File
@@ -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
View File
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_link_rows
from app.models.models import SysPost, SysPostRole, SysRole, SysUser, SysUserPost
@@ -232,7 +233,7 @@ def sync_post_role_bindings(
role_ids: List[str],
updater_id: Optional[str],
) -> None:
"""将本职位绑定的角色集合全量替换为 role_ids。"""
"""按角色更新本职位的角色关联。"""
ordered = list(dict.fromkeys([rid for rid in (role_ids or []) if rid]))
for rid in ordered:
role = (
@@ -249,31 +250,18 @@ def sync_post_role_bindings(
if not role:
raise ValueError("存在无效角色或不属于当前租户")
now = datetime.now()
db.query(SysPostRole).filter(
and_(
SysPostRole.post_id == post_id,
SysPostRole.tenant_no == tenant_no,
SysPostRole.del_flag == 0,
)
).update({"del_flag": 1, "update_time": now, "update_by": updater_id}, synchronize_session=False)
for rid in ordered:
db.add(
SysPostRole(
id=generate_uuid(),
post_id=post_id,
role_id=rid,
tenant_no=tenant_no,
order_num=0,
remark=None,
create_by=updater_id,
create_dept_id="",
create_time=now,
update_by=updater_id,
update_time=now,
del_flag=0,
)
)
sync_link_rows(
db,
SysPostRole,
parent_attr="post_id",
parent_id=post_id,
link_attr="role_id",
link_ids=ordered,
tenant_no=tenant_no,
user_id=updater_id,
duplicate_message="角色重复",
foreign_message="角色关联不属于当前职位",
)
db.commit()
+34 -29
View File
@@ -11,6 +11,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid, parse_datetime
from app.crud.child_row_sync import sync_child_rows
from app.crud.biz_no_generator import allocate_next_biz_no
from app.crud.collection_protocol import urls_to_attachment_list
from app.models.models import (
@@ -337,41 +338,45 @@ def replace_sample_types(
create_dept_id: str = "",
) -> None:
now = _now()
existing = (
db.query(BizPresignOrderSampleType)
.filter(
BizPresignOrderSampleType.presign_order_id == presign_order_id,
BizPresignOrderSampleType.tenant_no == tenant_no,
BizPresignOrderSampleType.del_flag == 0,
)
.all()
)
for r in existing:
r.del_flag = 1
r.update_by = user_id
r.update_time = now
db.add(r)
payload: List[Dict[str, Any]] = []
seen = set()
for idx, tid in enumerate(sample_type_ids or []):
for tid in sample_type_ids or []:
sid = (tid or "").strip()
if not sid or sid in seen:
continue
seen.add(sid)
db.add(
BizPresignOrderSampleType(
id=generate_uuid(),
presign_order_id=presign_order_id,
sample_type_id=sid,
tenant_no=tenant_no,
order_num=idx,
create_by=user_id,
create_dept_id=create_dept_id,
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
payload.append({"sample_type_id": sid})
def build_row(row_id: str) -> BizPresignOrderSampleType:
return BizPresignOrderSampleType(
id=row_id,
presign_order_id=presign_order_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id=create_dept_id,
create_time=now,
)
def apply_row(row: BizPresignOrderSampleType, item: Dict[str, Any], idx: int, _now: datetime) -> None:
row.sample_type_id = item["sample_type_id"]
row.order_num = idx
sync_child_rows(
db,
BizPresignOrderSampleType,
parent_attr="presign_order_id",
parent_id=presign_order_id,
tenant_no=tenant_no,
items=payload,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda item: (item.get("sample_type_id") or "").strip(),
row_natural_key=lambda row: (row.sample_type_id or "").strip(),
duplicate_message="样本类型编号重复",
foreign_message="样本类型不属于当前预签约订单",
)
db.flush()
+132 -12
View File
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.models.models import (
BizProcess,
BizProcessCategory,
@@ -300,6 +301,132 @@ def _create_sub_data_collection(db: Session, process_id: str, tenant_no: str, us
db.add(obj)
def _new_process_child(model, row_id: str, process_id: str, tenant_no: str, user_id: Optional[str], now: datetime):
return model(
id=row_id,
process_id=process_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def _sync_process_reagents(db, process_id, tenant_no, user_id, now, items):
def apply_row(row, data, idx, _now):
item = _sub_item_with_order(data, idx)
row.reagent_code = (item.get("reagent_code") or "").strip()
row.reagent_name = (item.get("reagent_name") or "").strip()
row.spec = (item.get("spec") or "").strip() or None
row.standard_usage = (item.get("standard_usage") or "").strip() or None
row.unit = (item.get("unit") or "").strip() or None
row.is_key_material = item.get("is_key_material", 0)
row.remark = (item.get("remark") or "").strip() or None
row.order_num = item.get("order_num", 0) if item.get("order_num") is not None else 0
sync_child_rows(
db,
BizProcessReagent,
parent_attr="process_id",
parent_id=process_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=lambda row_id: _new_process_child(BizProcessReagent, row_id, process_id, tenant_no, user_id, now),
apply_fields=apply_row,
natural_key=lambda item: (item.get("reagent_code") or "").strip(),
row_natural_key=lambda row: (row.reagent_code or "").strip(),
duplicate_message="试剂编号重复",
foreign_message="试剂不属于当前工艺",
)
def _sync_process_consumables(db, process_id, tenant_no, user_id, now, items):
def apply_row(row, data, idx, _now):
item = _sub_item_with_order(data, idx)
row.consumable_code = (item.get("consumable_code") or "").strip()
row.consumable_name = (item.get("consumable_name") or "").strip()
row.spec_model = (item.get("spec_model") or "").strip() or None
row.standard_usage = (item.get("standard_usage") or "").strip() or None
row.unit = (item.get("unit") or "").strip() or None
row.is_required = item.get("is_required", 0)
row.remark = (item.get("remark") or "").strip() or None
row.order_num = item.get("order_num", 0) if item.get("order_num") is not None else 0
sync_child_rows(
db,
BizProcessConsumable,
parent_attr="process_id",
parent_id=process_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=lambda row_id: _new_process_child(BizProcessConsumable, row_id, process_id, tenant_no, user_id, now),
apply_fields=apply_row,
natural_key=lambda item: (item.get("consumable_code") or "").strip(),
row_natural_key=lambda row: (row.consumable_code or "").strip(),
duplicate_message="耗材编号重复",
foreign_message="耗材不属于当前工艺",
)
def _sync_process_outputs(db, process_id, tenant_no, user_id, now, items):
def apply_row(row, data, idx, _now):
item = _sub_item_with_order(data, idx)
row.output_type_id = (item.get("output_type_id") or "").strip() or None
row.output_name = (item.get("output_name") or "").strip() or None
row.output_barcode = (item.get("output_barcode") or "").strip() or None
row.remark = (item.get("remark") or "").strip() or None
row.order_num = item.get("order_num", 0) if item.get("order_num") is not None else 0
sync_child_rows(
db,
BizProcessOutput,
parent_attr="process_id",
parent_id=process_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=lambda row_id: _new_process_child(BizProcessOutput, row_id, process_id, tenant_no, user_id, now),
apply_fields=apply_row,
natural_key=lambda item: (item.get("output_barcode") or item.get("output_name") or "").strip(),
row_natural_key=lambda row: (row.output_barcode or row.output_name or "").strip(),
duplicate_message="产出物编号重复",
foreign_message="产出物不属于当前工艺",
)
def _sync_process_data_collections(db, process_id, tenant_no, user_id, now, items):
def apply_row(row, data, idx, _now):
row.collection_name = (data.get("collection_name") or "").strip()
row.unit = (data.get("unit") or "").strip() or None
order_num = data.get("order_num")
row.order_num = idx if order_num is None else order_num
row.remark = (data.get("remark") or "").strip() or None
sync_child_rows(
db,
BizProcessDataCollection,
parent_attr="process_id",
parent_id=process_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=lambda row_id: _new_process_child(
BizProcessDataCollection, row_id, process_id, tenant_no, user_id, now
),
apply_fields=apply_row,
natural_key=lambda item: (item.get("collection_name") or "").strip(),
row_natural_key=lambda row: (row.collection_name or "").strip(),
duplicate_message="数据采集编号重复",
foreign_message="数据采集不属于当前工艺",
)
def create_process(
db: Session,
*,
@@ -434,22 +561,15 @@ def update_process(
sub_updated = reagents is not None or consumables is not None or outputs is not None or data_collections is not None
if sub_updated:
now = datetime.now()
tenant_no = obj.tenant_no
if reagents is not None:
_soft_delete_sub_records(db, BizProcessReagent, process_id, now)
for idx, r in enumerate(reagents):
_create_sub_reagent(db, process_id, obj.tenant_no, user_id, now, _sub_item_with_order(r, idx))
_sync_process_reagents(db, process_id, tenant_no, user_id, now, reagents)
if consumables is not None:
_soft_delete_sub_records(db, BizProcessConsumable, process_id, now)
for idx, c in enumerate(consumables):
_create_sub_consumable(db, process_id, obj.tenant_no, user_id, now, _sub_item_with_order(c, idx))
_sync_process_consumables(db, process_id, tenant_no, user_id, now, consumables)
if outputs is not None:
_soft_delete_sub_records(db, BizProcessOutput, process_id, now)
for idx, o in enumerate(outputs):
_create_sub_output(db, process_id, obj.tenant_no, user_id, now, _sub_item_with_order(o, idx))
_sync_process_outputs(db, process_id, tenant_no, user_id, now, outputs)
if data_collections is not None:
_soft_delete_sub_records(db, BizProcessDataCollection, process_id, now)
for d in data_collections:
_create_sub_data_collection(db, process_id, obj.tenant_no, user_id, now, d)
_sync_process_data_collections(db, process_id, tenant_no, user_id, now, data_collections)
db.commit()
db.refresh(obj)
+123 -13
View File
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.crud.equipment_snapshot import parse_equipment_ids, resolve_equipment_list_snapshot
from app.models.models import (
BizProductionProductType,
@@ -652,38 +653,147 @@ def soft_delete_route(db: Session, route_id: str) -> None:
# ---------------------------------------------------------------------------
# 子表完整替换更新
# 子表按行更新:保留原行 ID,去掉的才软删除
# ---------------------------------------------------------------------------
def _new_route_step_child(model, row_id, step_id, tenant_no, user_id, now):
return model(
id=row_id,
step_id=step_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def update_step_reagents(db: Session, step_id: str, items: List[Dict], tenant_no: str, user_id: Optional[str] = None):
now = datetime.now()
_soft_delete_sub_records(db, BizProcessRouteStepReagent, step_id, now)
for it in items:
_create_step_reagent(db, step_id, tenant_no, user_id, now, it)
def apply_row(row, data, idx, _now):
row.reagent_code = (data.get("reagent_code") or "").strip() or None
row.reagent_name = (data.get("reagent_name") or "").strip() or None
row.spec = (data.get("spec") or "").strip() or None
row.standard_usage = (data.get("standard_usage") or "").strip() or None
row.unit = (data.get("unit") or "").strip() or None
row.is_key_material = data.get("is_key_material", "0")
row.remark = (data.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProcessRouteStepReagent,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=lambda row_id: _new_route_step_child(
BizProcessRouteStepReagent, row_id, step_id, tenant_no, user_id, now
),
apply_fields=apply_row,
natural_key=lambda item: (item.get("reagent_code") or "").strip(),
row_natural_key=lambda row: (row.reagent_code or "").strip(),
duplicate_message="试剂编号重复",
foreign_message="试剂不属于当前步骤",
)
db.commit()
def update_step_consumables(db: Session, step_id: str, items: List[Dict], tenant_no: str, user_id: Optional[str] = None):
now = datetime.now()
_soft_delete_sub_records(db, BizProcessRouteStepConsumable, step_id, now)
for it in items:
_create_step_consumable(db, step_id, tenant_no, user_id, now, it)
def apply_row(row, data, idx, _now):
row.consumable_code = (data.get("consumable_code") or "").strip() or None
row.consumable_name = (data.get("consumable_name") or "").strip() or None
row.spec_model = (data.get("spec_model") or "").strip() or None
row.standard_usage = (data.get("standard_usage") or "").strip() or None
row.unit = (data.get("unit") or "").strip() or None
row.is_required = data.get("is_required", "1")
row.remark = (data.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProcessRouteStepConsumable,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=lambda row_id: _new_route_step_child(
BizProcessRouteStepConsumable, row_id, step_id, tenant_no, user_id, now
),
apply_fields=apply_row,
natural_key=lambda item: (item.get("consumable_code") or "").strip(),
row_natural_key=lambda row: (row.consumable_code or "").strip(),
duplicate_message="耗材编号重复",
foreign_message="耗材不属于当前步骤",
)
db.commit()
def update_step_outputs(db: Session, step_id: str, items: List[Dict], tenant_no: str, user_id: Optional[str] = None):
now = datetime.now()
_soft_delete_sub_records(db, BizProcessRouteStepOutput, step_id, now)
for it in items:
_create_step_output(db, step_id, tenant_no, user_id, now, it)
def apply_row(row, data, idx, _now):
row.output_type_id = (data.get("output_type_id") or "").strip() or None
row.output_name = (data.get("output_name") or "").strip() or None
row.output_barcode = (data.get("output_barcode") or "").strip() or None
row.remark = (data.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProcessRouteStepOutput,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=lambda row_id: _new_route_step_child(
BizProcessRouteStepOutput, row_id, step_id, tenant_no, user_id, now
),
apply_fields=apply_row,
natural_key=lambda item: (item.get("output_barcode") or item.get("output_name") or "").strip(),
row_natural_key=lambda row: (row.output_barcode or row.output_name or "").strip(),
duplicate_message="产出物编号重复",
foreign_message="产出物不属于当前步骤",
)
db.commit()
def update_step_data_collections(db: Session, step_id: str, items: List[Dict], tenant_no: str, user_id: Optional[str] = None):
now = datetime.now()
_soft_delete_sub_records(db, BizProcessRouteStepDataCollection, step_id, now)
for it in items:
_create_step_data_collection(db, step_id, tenant_no, user_id, now, it)
def apply_row(row, data, idx, _now):
row.collection_name = (data.get("collection_name") or "").strip() or None
row.unit = (data.get("unit") or "").strip() or None
order_num = data.get("order_num")
row.order_num = idx if order_num is None else (order_num or 0)
row.remark = (data.get("remark") or "").strip() or None
sync_child_rows(
db,
BizProcessRouteStepDataCollection,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=lambda row_id: _new_route_step_child(
BizProcessRouteStepDataCollection, row_id, step_id, tenant_no, user_id, now
),
apply_fields=apply_row,
natural_key=lambda item: (item.get("collection_name") or "").strip(),
row_natural_key=lambda row: (row.collection_name or "").strip(),
duplicate_message="数据采集编号重复",
foreign_message="数据采集不属于当前步骤",
)
db.commit()
+50 -37
View File
@@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional
from sqlalchemy import and_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.crud.production_material import _dec, _fmt_dt
from app.models.production_material import BizProductionMaterial, BizProductionMaterialFormula
@@ -84,7 +84,7 @@ def replace_formula(
raise ValueError("请至少添加一条配方明细")
seen_components: set = set()
new_rows: List[BizProductionMaterialFormula] = []
normalized: List[Dict[str, Any]] = []
for idx, it in enumerate(lines):
comp_id = (
@@ -125,45 +125,58 @@ def replace_formula(
it.get("usageUnit") or it.get("usage_unit") or comp.unit or ""
).strip()[:32]
new_rows.append(
BizProductionMaterialFormula(
id=generate_uuid(),
configured_material_id=configured_material_id,
component_material_id=comp_id,
component_material_type=comp_type,
component_material_code=comp.material_code,
component_material_name=comp.material_name,
standard_qty=standard_qty,
usage_unit=usage_unit or None,
tenant_no=tenant_no,
order_num=idx,
remark=(it.get("remark") or "").strip()[:512] or None,
create_by=user_id,
create_dept_id=dept_id or configured.create_dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
normalized.append(
{
"id": (it.get("id") or "").strip(),
"component_material_id": comp_id,
"component_material_type": comp_type,
"component_material_code": comp.material_code,
"component_material_name": comp.material_name,
"standard_qty": standard_qty,
"usage_unit": usage_unit or None,
"order_num": idx,
"remark": (it.get("remark") or "").strip()[:512] or None,
}
)
existing = (
db.query(BizProductionMaterialFormula)
.filter(
BizProductionMaterialFormula.configured_material_id == configured_material_id,
BizProductionMaterialFormula.tenant_no == tenant_no,
BizProductionMaterialFormula.del_flag == 0,
dept = dept_id or configured.create_dept_id or ""
def build_row(row_id: str) -> BizProductionMaterialFormula:
return BizProductionMaterialFormula(
id=row_id,
configured_material_id=configured_material_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id=dept,
create_time=now,
)
.all()
def apply_row(row: BizProductionMaterialFormula, it: Dict[str, Any], idx: int, _now: datetime) -> None:
row.component_material_id = it["component_material_id"]
row.component_material_type = it["component_material_type"]
row.component_material_code = it["component_material_code"]
row.component_material_name = it["component_material_name"]
row.standard_qty = it["standard_qty"]
row.usage_unit = it["usage_unit"]
row.order_num = idx
row.remark = it["remark"]
sync_child_rows(
db,
BizProductionMaterialFormula,
parent_attr="configured_material_id",
parent_id=configured_material_id,
tenant_no=tenant_no,
items=normalized,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: (it.get("component_material_id") or "").strip(),
row_natural_key=lambda row: (row.component_material_id or "").strip(),
duplicate_message="配方明细编号重复",
foreign_message="配方明细不属于当前配置试剂",
)
for row in existing:
row.del_flag = 1
row.update_by = user_id
row.update_time = now
db.add(row)
for row in new_rows:
db.add(row)
db.commit()
return get_formula(db, configured_material_id, tenant_no)
+56 -34
View File
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid, parse_datetime
from app.crud.child_row_sync import sync_child_rows
from app.crud.production_material import _dec, _fmt_dt, _is_enabled_status
from app.crud.production_material_stock_batch import (
_create_batch_and_usage,
@@ -1007,44 +1008,65 @@ def update_prep(
lines_raw = data.get("lines")
if lines_raw is not None:
parsed_lines = _parse_prep_lines(db, lines_raw, tenant_no=tenant_no)
existing = (
db.query(BizProductionMaterialPrepLine)
.filter(
BizProductionMaterialPrepLine.prep_id == prep_id,
BizProductionMaterialPrepLine.del_flag == 0,
)
.all()
)
for ln in existing:
ln.del_flag = 1
ln.update_by = user_id
ln.update_time = now
db.add(ln)
line_items = []
for pl in parsed_lines:
comp = pl["component"]
db.add(
BizProductionMaterialPrepLine(
id=generate_uuid(),
prep_id=prep_id,
component_material_id=comp.id,
component_material_type=pl["component_type"],
component_material_code=comp.material_code,
component_material_name=comp.material_name,
requisition_usage_id=pl["requisition"].id,
actual_qty=pl["actual_qty"],
usage_unit=pl["usage_unit"] or None,
tenant_no=tenant_no,
order_num=pl["idx"],
remark=pl["remark"],
create_by=user_id,
create_dept_id=dept_id or prep.create_dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
line_items.append(
{
"id": (pl.get("id") or "").strip(),
"component_material_id": comp.id,
"component_material_type": pl["component_type"],
"component_material_code": comp.material_code,
"component_material_name": comp.material_name,
"requisition_usage_id": pl["requisition"].id,
"actual_qty": pl["actual_qty"],
"usage_unit": pl["usage_unit"] or None,
"order_num": pl["idx"],
"remark": pl["remark"],
}
)
def build_line(row_id):
return BizProductionMaterialPrepLine(
id=row_id,
prep_id=prep_id,
component_material_id="",
component_material_type="",
actual_qty=0,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id=dept_id or prep.create_dept_id or "",
create_time=now,
)
def apply_line(row, item, _idx, _now):
row.component_material_id = item["component_material_id"]
row.component_material_type = item["component_material_type"]
row.component_material_code = item["component_material_code"]
row.component_material_name = item["component_material_name"]
row.requisition_usage_id = item["requisition_usage_id"]
row.actual_qty = item["actual_qty"]
row.usage_unit = item["usage_unit"]
row.order_num = item["order_num"]
row.remark = item["remark"]
sync_child_rows(
db,
BizProductionMaterialPrepLine,
parent_attr="prep_id",
parent_id=prep_id,
tenant_no=tenant_no,
items=line_items,
user_id=user_id,
now=now,
build_row=build_line,
apply_fields=apply_line,
natural_key=lambda item: f"{item['component_material_id']}:{item['requisition_usage_id'] or ''}",
row_natural_key=lambda row: f"{row.component_material_id}:{row.requisition_usage_id or ''}",
duplicate_message="配制明细重复",
foreign_message="配制明细不属于当前配制单",
)
prep.update_by = user_id
prep.update_time = now
db.add(prep)
@@ -9,6 +9,7 @@ from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.crud.production_material_stock_batch import _dec, _fmt_date, _fmt_dt, _parse_date, _require_str
from app.models.models import SysUser
from app.models.production_material import (
@@ -449,6 +450,47 @@ def _resolve_unused_item_fields(
return out
def _unused_line_key(usage_id: Optional[str], batch_id: Optional[str]) -> str:
usage = (usage_id or "").strip()
if usage:
return f"usage:{usage}"
batch = (batch_id or "").strip()
if batch:
return f"batch:{batch}"
return ""
def _prepare_unused_item(db: Session, it: Dict[str, Any], tenant_no: str) -> Dict[str, Any]:
it = _resolve_unused_item_fields(db, it, tenant_no)
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
if qty is None or Decimal(str(qty)) <= 0:
raise ValueError("未用完数量必须大于0")
unit_price = it.get("unit_price") if it.get("unit_price") is not None else it.get("unitPrice")
material_type = it.get("material_type") if "material_type" in it else it.get("materialType")
return {
"id": str(it.get("id") or "").strip(),
"usage_id": (it.get("usage_id") or it.get("usageId") or "").strip() or None,
"batch_id": (it.get("batch_id") or it.get("batchId") or "").strip() or None,
"material_type": (material_type or "").strip() or None if material_type is not None else None,
"has_material_type": "material_type" in it or "materialType" in it,
"material_code": (it.get("material_code") or it.get("materialCode") or "").strip() or None,
"material_name": name,
"spec_model": (it.get("spec_model") or it.get("specModel") or "").strip() or None,
"unit": (it.get("unit") or "").strip() or None,
"stock_in_no": (it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
"stock_in_date": _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate")),
"expire_date": _parse_optional_date(it.get("expire_date") or it.get("expireDate")),
"catalog_no": (it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
"batch_no": (it.get("batch_no") or it.get("batchNo") or "").strip() or None,
"manufacturer": (it.get("manufacturer") or "").strip() or None,
"unit_price": Decimal(str(unit_price)) if unit_price is not None else None,
"holder": (it.get("holder") or "").strip() or None,
"unused_qty": Decimal(str(qty)),
"remark": (it.get("remark") or "").strip() or None,
}
def save_unused_lines(
db: Session,
check_id: str,
@@ -465,73 +507,63 @@ def save_unused_lines(
raise ValueError("仅进行中的盘点单可登记未用完物料")
now = datetime.now()
old_rows = (
db.query(BizProductionMaterialStockCheckUnusedLine)
.filter(
BizProductionMaterialStockCheckUnusedLine.check_id == check_id,
BizProductionMaterialStockCheckUnusedLine.del_flag == 0,
)
.all()
)
for r in old_rows:
r.del_flag = 1
r.update_by = user_id
r.update_time = now
db.add(r)
prepared = [_prepare_unused_item(db, it, tenant_no) for it in (items or [])]
count = 0
for it in items or []:
it = _resolve_unused_item_fields(db, it, tenant_no)
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
if qty is None or Decimal(str(qty)) <= 0:
raise ValueError("未用完数量必须大于0")
qty_dec = Decimal(str(qty))
stock_in_date = _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate"))
expire_date = _parse_optional_date(it.get("expire_date") or it.get("expireDate"))
usage_id = (it.get("usage_id") or it.get("usageId") or "").strip() or None
batch_id = (it.get("batch_id") or it.get("batchId") or "").strip() or None
row = BizProductionMaterialStockCheckUnusedLine(
id=generate_uuid(),
def build_row(row_id: str):
return BizProductionMaterialStockCheckUnusedLine(
id=row_id,
check_id=check_id,
usage_id=usage_id,
batch_id=batch_id,
material_type=(it.get("material_type") or it.get("materialType") or "").strip() or None,
material_code=(it.get("material_code") or it.get("materialCode") or "").strip() or None,
material_name=name,
spec_model=(it.get("spec_model") or it.get("specModel") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
stock_in_no=(it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
stock_in_date=stock_in_date,
expire_date=expire_date,
catalog_no=(it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
batch_no=(it.get("batch_no") or it.get("batchNo") or "").strip() or None,
manufacturer=(it.get("manufacturer") or "").strip() or None,
unit_price=(
Decimal(str(it.get("unit_price") or it.get("unitPrice")))
if (it.get("unit_price") is not None or it.get("unitPrice") is not None)
else None
),
holder=(it.get("holder") or "").strip() or None,
unused_qty=qty_dec,
material_name="",
unused_qty=0,
tenant_no=tenant_no,
remark=(it.get("remark") or "").strip() or None,
create_by=user_id,
create_dept_id=dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(row)
count += 1
def apply_row(row, it, idx, _now):
row.usage_id = it["usage_id"]
row.batch_id = it["batch_id"]
if it["has_material_type"]:
row.material_type = it["material_type"]
row.material_code = it["material_code"]
row.material_name = it["material_name"]
row.spec_model = it["spec_model"]
row.unit = it["unit"]
row.stock_in_no = it["stock_in_no"]
row.stock_in_date = it["stock_in_date"]
row.expire_date = it["expire_date"]
row.catalog_no = it["catalog_no"]
row.batch_no = it["batch_no"]
row.manufacturer = it["manufacturer"]
row.unit_price = it["unit_price"]
row.holder = it["holder"]
row.unused_qty = it["unused_qty"]
row.remark = it["remark"]
row.order_num = idx
saved = sync_child_rows(
db,
BizProductionMaterialStockCheckUnusedLine,
parent_attr="check_id",
parent_id=check_id,
tenant_no=tenant_no,
items=prepared,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: _unused_line_key(it.get("usage_id"), it.get("batch_id")),
row_natural_key=lambda row: _unused_line_key(row.usage_id, row.batch_id),
duplicate_message="未用完明细重复",
foreign_message="未用完明细不属于当前盘点单",
)
check.update_by = user_id
check.update_time = now
db.add(check)
db.commit()
return count
return len(saved)
def complete_check(
+337 -119
View File
@@ -9,7 +9,9 @@ from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import format_date, generate_uuid, parse_datetime
from app.crud.child_row_sync import sync_child_rows
from app.crud.equipment_snapshot import parse_equipment_ids, resolve_equipment_list_snapshot
from app.crud.user_display import user_display_name_map
from app.models.models import (
BizCellProduct,
BizCollectionProtocol,
@@ -727,12 +729,7 @@ def _enrich_assignee_names(db: Session, items: List[Dict[str, Any]]) -> None:
user_ids = list({(x.get("currentAssigneeUserId") or "").strip() for x in items if (x.get("currentAssigneeUserId") or "").strip()})
if not user_ids:
return
rows = (
db.query(SysUser.id, SysUser.nick_name)
.filter(SysUser.id.in_(user_ids), SysUser.del_flag == 0)
.all()
)
name_map = {r.id: (r.nick_name or "").strip() for r in rows}
name_map = user_display_name_map(db, user_ids)
for item in items:
uid = (item.get("currentAssigneeUserId") or "").strip()
if uid:
@@ -2306,10 +2303,17 @@ def replace_step_reagents(
task_no = task_row.task_no or ""
try:
_soft_delete_sub(db, BizProductionTaskStepReagent, "step_id", step_id, now)
def build_row(row_id: str) -> BizProductionTaskStepReagent:
return BizProductionTaskStepReagent(
id=row_id,
step_id=step_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
usage_items: List[Dict[str, Any]] = []
for idx, it in enumerate(items):
def apply_row(row: BizProductionTaskStepReagent, it: Dict[str, Any], idx: int, _now: datetime) -> None:
req_id = (
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
)
@@ -2321,20 +2325,39 @@ def replace_step_reagents(
it.get("actual_usage"),
req_id,
)
db.add(BizProductionTaskStepReagent(
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
reagent_code=(it.get("reagent_code") or "").strip() or None,
reagent_name=(it.get("reagent_name") or "").strip() or None,
spec=(it.get("spec") or "").strip() or None,
standard_usage=(it.get("standard_usage") or "").strip() or None,
actual_usage=(it.get("actual_usage") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
requisition_usage_id=req_id or None,
is_key_material=it.get("is_key_material", "0"),
remark=(it.get("remark") or "").strip() or None,
order_num=idx, create_by=user_id, create_dept_id="",
create_time=now, update_by=user_id, update_time=now, del_flag=0,
))
row.reagent_code = (it.get("reagent_code") or "").strip() or None
row.reagent_name = (it.get("reagent_name") or "").strip() or None
row.spec = (it.get("spec") or "").strip() or None
row.standard_usage = (it.get("standard_usage") or "").strip() or None
row.actual_usage = (it.get("actual_usage") or "").strip() or None
row.unit = (it.get("unit") or "").strip() or None
row.requisition_usage_id = req_id or None
row.is_key_material = it.get("is_key_material", "0")
row.remark = (it.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProductionTaskStepReagent,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: (it.get("reagent_code") or "").strip(),
row_natural_key=lambda row: (row.reagent_code or "").strip(),
duplicate_message="试剂编号重复",
foreign_message="试剂不属于当前步骤",
)
usage_items: List[Dict[str, Any]] = []
for it in items:
req_id = (
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
)
if req_id:
usage_items.append({
"requisitionUsageId": req_id,
@@ -2376,10 +2399,17 @@ def replace_step_consumables(
task_no = task_row.task_no or ""
try:
_soft_delete_sub(db, BizProductionTaskStepConsumable, "step_id", step_id, now)
def build_row(row_id: str) -> BizProductionTaskStepConsumable:
return BizProductionTaskStepConsumable(
id=row_id,
step_id=step_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
usage_items: List[Dict[str, Any]] = []
for idx, it in enumerate(items):
def apply_row(row: BizProductionTaskStepConsumable, it: Dict[str, Any], idx: int, _now: datetime) -> None:
req_id = (
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
)
@@ -2391,20 +2421,39 @@ def replace_step_consumables(
it.get("actual_usage"),
req_id,
)
db.add(BizProductionTaskStepConsumable(
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
consumable_code=(it.get("consumable_code") or "").strip() or None,
consumable_name=(it.get("consumable_name") or "").strip() or None,
spec_model=(it.get("spec_model") or "").strip() or None,
standard_usage=(it.get("standard_usage") or "").strip() or None,
actual_usage=(it.get("actual_usage") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
requisition_usage_id=req_id or None,
is_required=it.get("is_required", "1"),
remark=(it.get("remark") or "").strip() or None,
order_num=idx, create_by=user_id, create_dept_id="",
create_time=now, update_by=user_id, update_time=now, del_flag=0,
))
row.consumable_code = (it.get("consumable_code") or "").strip() or None
row.consumable_name = (it.get("consumable_name") or "").strip() or None
row.spec_model = (it.get("spec_model") or "").strip() or None
row.standard_usage = (it.get("standard_usage") or "").strip() or None
row.actual_usage = (it.get("actual_usage") or "").strip() or None
row.unit = (it.get("unit") or "").strip() or None
row.requisition_usage_id = req_id or None
row.is_required = it.get("is_required", "1")
row.remark = (it.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProductionTaskStepConsumable,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: (it.get("consumable_code") or "").strip(),
row_natural_key=lambda row: (row.consumable_code or "").strip(),
duplicate_message="耗材编号重复",
foreign_message="耗材不属于当前步骤",
)
usage_items: List[Dict[str, Any]] = []
for it in items:
req_id = (
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
)
if req_id:
usage_items.append({
"requisitionUsageId": req_id,
@@ -2445,10 +2494,22 @@ def replace_step_configured_reagents(
task_no = task_row.task_no or ""
try:
_soft_delete_sub(db, BizProductionTaskStepConfiguredReagent, "step_id", step_id, now)
def build_row(row_id: str) -> BizProductionTaskStepConfiguredReagent:
return BizProductionTaskStepConfiguredReagent(
id=row_id,
step_id=step_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
usage_items: List[Dict[str, Any]] = []
for idx, it in enumerate(items):
def apply_row(
row: BizProductionTaskStepConfiguredReagent,
it: Dict[str, Any],
idx: int,
_now: datetime,
) -> None:
req_id = (
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
)
@@ -2460,19 +2521,38 @@ def replace_step_configured_reagents(
it.get("actual_usage"),
req_id,
)
db.add(BizProductionTaskStepConfiguredReagent(
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
configured_reagent_code=(it.get("configured_reagent_code") or "").strip() or None,
configured_reagent_name=(it.get("configured_reagent_name") or "").strip() or None,
spec=(it.get("spec") or "").strip() or None,
standard_usage=(it.get("standard_usage") or "").strip() or None,
actual_usage=(it.get("actual_usage") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
requisition_usage_id=req_id or None,
remark=(it.get("remark") or "").strip() or None,
order_num=idx, create_by=user_id, create_dept_id="",
create_time=now, update_by=user_id, update_time=now, del_flag=0,
))
row.configured_reagent_code = (it.get("configured_reagent_code") or "").strip() or None
row.configured_reagent_name = (it.get("configured_reagent_name") or "").strip() or None
row.spec = (it.get("spec") or "").strip() or None
row.standard_usage = (it.get("standard_usage") or "").strip() or None
row.actual_usage = (it.get("actual_usage") or "").strip() or None
row.unit = (it.get("unit") or "").strip() or None
row.requisition_usage_id = req_id or None
row.remark = (it.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProductionTaskStepConfiguredReagent,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: (it.get("configured_reagent_code") or "").strip(),
row_natural_key=lambda row: (row.configured_reagent_code or "").strip(),
duplicate_message="配置试剂编号重复",
foreign_message="配置试剂不属于当前步骤",
)
usage_items: List[Dict[str, Any]] = []
for it in items:
req_id = (
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
)
if req_id:
usage_items.append({
"requisitionUsageId": req_id,
@@ -2492,23 +2572,62 @@ def replace_step_configured_reagents(
raise
def _apply_step_output_fields(
row: BizProductionTaskStepOutput,
item: Dict[str, Any],
*,
order_num: int,
user_id: Optional[str],
now: datetime,
) -> None:
row.output_type_id = (item.get("output_type_id") or "").strip() or None
row.output_name = (item.get("output_name") or "").strip() or None
row.output_barcode = (item.get("output_barcode") or "").strip() or None
row.remark = (item.get("remark") or "").strip() or None
row.order_num = order_num
row.update_by = user_id
row.update_time = now
row.del_flag = 0
def replace_step_outputs(
db: Session, step_id: str, tenant_no: str,
user_id: Optional[str], items: List[Dict[str, Any]],
) -> None:
) -> List[Dict[str, Any]]:
"""按产出物 ID 更新原行。带 ID 的行原地修改,没有 ID 的才新增,提交列表里没有的才软删除。
质检工单用 production_step_output_id 指向这里。整表软删再插入会换掉 ID,关联就断了。
"""
now = datetime.now()
_soft_delete_sub(db, BizProductionTaskStepOutput, "step_id", step_id, now)
for idx, it in enumerate(items):
db.add(BizProductionTaskStepOutput(
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
output_type_id=(it.get("output_type_id") or "").strip() or None,
output_name=(it.get("output_name") or "").strip() or None,
output_barcode=(it.get("output_barcode") or "").strip() or None,
remark=(it.get("remark") or "").strip() or None,
order_num=idx, create_by=user_id, create_dept_id="",
create_time=now, update_by=user_id, update_time=now, del_flag=0,
))
def build_row(row_id: str) -> BizProductionTaskStepOutput:
return BizProductionTaskStepOutput(
id=row_id,
step_id=step_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
result_rows = sync_child_rows(
db,
BizProductionTaskStepOutput,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=lambda row, item, idx, now_: _apply_step_output_fields(
row, item, order_num=idx, user_id=user_id, now=now_
),
duplicate_message="产出物编号重复",
foreign_message="产出物不属于当前步骤",
)
db.commit()
return [_step_output_to_dict(row) for row in result_rows]
def replace_step_data_collections(
@@ -2516,17 +2635,45 @@ def replace_step_data_collections(
user_id: Optional[str], items: List[Dict[str, Any]],
) -> None:
now = datetime.now()
_soft_delete_sub(db, BizProductionTaskStepDataCollection, "step_id", step_id, now)
for idx, it in enumerate(items):
db.add(BizProductionTaskStepDataCollection(
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
collection_name=(it.get("collection_name") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
collection_value=(it.get("collection_value") or "").strip() or None,
remark=(it.get("remark") or "").strip() or None,
order_num=idx, create_by=user_id, create_dept_id="",
create_time=now, update_by=user_id, update_time=now, del_flag=0,
))
def build_row(row_id: str) -> BizProductionTaskStepDataCollection:
return BizProductionTaskStepDataCollection(
id=row_id,
step_id=step_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def apply_row(
row: BizProductionTaskStepDataCollection,
it: Dict[str, Any],
idx: int,
_now: datetime,
) -> None:
row.collection_name = (it.get("collection_name") or "").strip() or None
row.unit = (it.get("unit") or "").strip() or None
row.collection_value = (it.get("collection_value") or "").strip() or None
row.remark = (it.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProductionTaskStepDataCollection,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: (it.get("collection_name") or "").strip(),
row_natural_key=lambda row: (row.collection_name or "").strip(),
duplicate_message="数据采集编号重复",
foreign_message="数据采集不属于当前步骤",
)
db.commit()
@@ -2604,58 +2751,129 @@ def spawn_temporary_work_order_from_task(
}
def _task_input_identity(input_type: Optional[str], input_id: Optional[str], input_code: Optional[str]) -> str:
kind = (input_type or "").strip()
ident = (input_id or "").strip() or (input_code or "").strip()
if not kind or not ident:
return ""
return f"{kind}:{ident}"
def _task_input_item_identity(it: Dict[str, Any]) -> str:
return _task_input_identity(
it.get("input_type") or it.get("inputType"),
it.get("input_id") or it.get("inputId"),
it.get("input_code") or it.get("inputCode"),
)
def _set_cell_product_status(
db: Session,
product_id: Optional[str],
*,
user_id: Optional[str],
now: datetime,
to_status: str,
from_statuses: tuple,
) -> None:
pid = (product_id or "").strip()
if not pid:
return
product = db.query(BizCellProduct).filter(
BizCellProduct.id == pid, BizCellProduct.del_flag == 0
).first()
if product and product.product_status in from_statuses:
product.product_status = to_status
product.update_by = user_id
product.update_time = now
db.add(product)
def set_task_inputs(
db: Session, task_id: str, tenant_no: str,
user_id: Optional[str], items: List[Dict[str, Any]],
) -> None:
now = datetime.now()
# --- 1. 加载旧 inputs 并按 cell_product 恢复状态 ---
old_rows = db.query(BizProductionTaskInput).filter(
and_(
BizProductionTaskInput.task_id == task_id,
BizProductionTaskInput.tenant_no == tenant_no,
BizProductionTaskInput.del_flag == 0,
)
).all()
old_identity = {
row.id: (row.input_type or "", row.input_id or "")
for row in old_rows
}
def build_row(row_id: str) -> BizProductionTaskInput:
return BizProductionTaskInput(
id=row_id,
task_id=task_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def apply_row(row: BizProductionTaskInput, it: Dict[str, Any], idx: int, _now: datetime) -> None:
row.input_type = (it.get("input_type") or it.get("inputType") or "").strip() or None
row.input_id = (it.get("input_id") or it.get("inputId") or "").strip() or None
row.input_code = (it.get("input_code") or it.get("inputCode") or "").strip() or None
row.input_name = (it.get("input_name") or it.get("inputName") or "").strip() or None
row.remark = (it.get("remark") or "").strip() or None
row.order_num = idx
saved = sync_child_rows(
db,
BizProductionTaskInput,
parent_attr="task_id",
parent_id=task_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=_task_input_item_identity,
row_natural_key=lambda row: _task_input_identity(row.input_type, row.input_id, row.input_code),
duplicate_message="原材料编号重复",
foreign_message="原材料不属于当前任务",
)
saved_ids = {row.id for row in saved}
for old_row in old_rows:
# 如果是细胞产品,恢复状态
if old_row.input_type == "cell_product" and old_row.input_id:
product = db.query(BizCellProduct).filter(
BizCellProduct.id == old_row.input_id, BizCellProduct.del_flag == 0
).first()
if product and product.product_status == "in_production":
product.product_status = "frozen"
product.update_by = user_id
product.update_time = now
db.add(product)
# --- 2. 全量软删除旧行 ---
_soft_delete_sub(db, BizProductionTaskInput, "task_id", task_id, now)
# --- 3. 按传入顺序批量新建 ---
for idx, it in enumerate(items):
inp_type = (it.get("input_type") or it.get("inputType") or "").strip()
inp_id = (it.get("input_id") or it.get("inputId") or "").strip()
# 如果是细胞产品,设置状态为 in_production
if inp_type == "cell_product" and inp_id:
product = db.query(BizCellProduct).filter(
BizCellProduct.id == inp_id, BizCellProduct.del_flag == 0
).first()
if product and product.product_status in ("frozen", "thawed"):
product.product_status = "in_production"
product.update_by = user_id
product.update_time = now
db.add(product)
db.add(BizProductionTaskInput(
id=generate_uuid(), task_id=task_id, tenant_no=tenant_no,
input_type=inp_type or None,
input_id=inp_id or None,
input_code=(it.get("input_code") or it.get("inputCode") or "").strip() or None,
input_name=(it.get("input_name") or it.get("inputName") or "").strip() or None,
remark=(it.get("remark") or "").strip() or None,
order_num=idx, create_by=user_id, create_dept_id="",
create_time=now, update_by=user_id, update_time=now, del_flag=0,
))
if old_row.id in saved_ids:
continue
if old_row.input_type == "cell_product":
_set_cell_product_status(
db,
old_row.input_id,
user_id=user_id,
now=now,
to_status="frozen",
from_statuses=("in_production",),
)
for row in saved:
previous = old_identity.get(row.id)
current = (row.input_type or "", row.input_id or "")
if previous != current and previous and previous[0] == "cell_product":
_set_cell_product_status(
db,
previous[1],
user_id=user_id,
now=now,
to_status="frozen",
from_statuses=("in_production",),
)
if row.input_type == "cell_product":
_set_cell_product_status(
db,
row.input_id,
user_id=user_id,
now=now,
to_status="in_production",
from_statuses=("frozen", "thawed"),
)
db.flush()
db.commit()
+39 -33
View File
@@ -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()
+58 -28
View File
@@ -11,6 +11,7 @@ from sqlalchemy import and_, exists, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid, parse_datetime
from app.crud.child_row_sync import sync_child_rows
from app.crud.collection_protocol import urls_to_attachment_list
from app.models.models import (
BizCellSampleType,
@@ -746,7 +747,7 @@ def replace_schedules(
user_id: Optional[str],
schedules: List[Dict[str, Any]],
) -> List[BizProtocolPaymentSchedule]:
"""仅在全部批次为 open、全部转账为 dfj 时重建批次。"""
"""仅在全部批次为 open、全部转账为 dfj 时按期次更新批次。去掉的期次连同草稿转账一起软删。"""
sp, old_schedules, transfers = _lock_sample_payment_graph(
db,
sample_payment_id=sample_payment_id,
@@ -755,45 +756,74 @@ def replace_schedules(
if _locked_schedule_ids(old_schedules, transfers):
raise ValueError("存在锁定批次,不能省略、删除、重编号或重建")
now = _now()
if old_schedules:
for t in transfers:
t.del_flag = 1
t.update_by = user_id
t.update_time = now
db.add(t)
for s in old_schedules:
s.del_flag = 1
s.update_by = user_id
s.update_time = now
db.add(s)
created: List[BizProtocolPaymentSchedule] = []
payload = []
for idx, raw in enumerate(schedules or []):
due_date = (raw.get("due_date") or raw.get("dueDate") or "").strip() or None
if not isinstance(raw, dict):
continue
schedule_no = _schedule_no_of(raw, idx + 1)
obj = BizProtocolPaymentSchedule(
id=generate_uuid(),
due_date = (raw.get("due_date") or raw.get("dueDate") or "").strip() or None
payload.append(
{
"id": (raw.get("id") or "").strip(),
"schedule_no": schedule_no,
"due_amount": _dec(
raw.get("due_amount") if "due_amount" in raw else raw.get("dueAmount")
),
"due_date": due_date,
"remark": (raw.get("remark") or "").strip() or None,
"order_num": idx,
}
)
def build_row(row_id):
return BizProtocolPaymentSchedule(
id=row_id,
sample_payment_id=sp.id,
collection_protocol_id=sp.collection_protocol_id,
sample_code=sp.sample_code,
schedule_no=schedule_no,
due_amount=_dec(raw.get("due_amount") if "due_amount" in raw else raw.get("dueAmount")),
due_date=due_date,
schedule_status="open",
tenant_no=tenant_no,
order_num=idx,
remark=(raw.get("remark") or "").strip() or None,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(obj)
created.append(obj)
db.flush()
def apply_row(row, item, _idx, _now):
row.collection_protocol_id = sp.collection_protocol_id
row.sample_code = sp.sample_code
row.schedule_no = item["schedule_no"]
row.due_amount = item["due_amount"]
row.due_date = item["due_date"]
row.schedule_status = row.schedule_status or "open"
row.remark = item["remark"]
row.order_num = item["order_num"]
created = sync_child_rows(
db,
BizProtocolPaymentSchedule,
parent_attr="sample_payment_id",
parent_id=sp.id,
tenant_no=tenant_no,
items=payload,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda item: str(item["schedule_no"]),
row_natural_key=lambda row: str(row.schedule_no or ""),
duplicate_message="应缴批次期次重复",
foreign_message="应缴批次不属于当前缴费方案",
reuse_deleted=True,
)
kept_ids = {row.id for row in created}
for transfer in transfers:
if transfer.schedule_id in kept_ids:
continue
transfer.del_flag = 1
transfer.update_by = user_id
transfer.update_time = now
db.add(transfer)
db.flush()
return created
+4 -14
View File
@@ -13,6 +13,7 @@ from app.crud import qc_task as task_svc
from app.crud import qc_task_assignee as assignee_svc
from app.crud import qc_third_party_item as third_item_svc
from app.crud.qc_third_party_company import get_company
from app.crud.user_display import user_display_name_map
from app.models.models import (
BizQcBatchAssignee,
BizQcTask,
@@ -20,7 +21,6 @@ from app.models.models import (
BizQcWorkOrderInspectionBatch,
BizQcWorkOrderLabItem,
BizQcWorkOrderThirdItem,
SysUser,
)
_CONFIGURABLE_WORK_ORDER_STATUSES = frozenset({"dpz", "ypz", "ywc", "yzf"})
@@ -180,13 +180,8 @@ def _batch_assignee_user_ids(db: Session, batch_id: str, tenant_no: str) -> List
def _assignee_names(db: Session, user_ids: List[str]) -> str:
if not user_ids:
return ""
rows = (
db.query(SysUser.id, SysUser.nick_name, SysUser.user_no)
.filter(SysUser.id.in_(user_ids), SysUser.del_flag == 0)
.all()
)
name_map = {r.id: (r.nick_name or r.user_no or r.id) for r in rows}
return "、".join(name_map.get(uid, uid) for uid in user_ids)
name_map = user_display_name_map(db, user_ids)
return "、".join(name_map.get(uid) or uid for uid in user_ids)
def _prefetch_batch_side_data(
@@ -248,12 +243,7 @@ def _prefetch_batch_side_data(
[uid for ids in assignees_by_batch.values() for uid in ids]
)
if all_user_ids:
for row in (
db.query(SysUser.id, SysUser.nick_name, SysUser.user_no)
.filter(SysUser.id.in_(all_user_ids), SysUser.del_flag == 0)
.all()
):
name_map[row.id] = row.nick_name or row.user_no or row.id
name_map = user_display_name_map(db, all_user_ids)
return {
"assignees_by_batch": assignees_by_batch,
+43 -34
View File
@@ -6,7 +6,7 @@ from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.models.qc_param import BizQcItemParam
@@ -80,18 +80,23 @@ def save_params_for_item(
_validate_param(p, idx)
now = datetime.now()
db.query(BizQcItemParam).filter(
BizQcItemParam.item_source == item_source,
BizQcItemParam.item_id == item_id,
BizQcItemParam.tenant_no == tenant_no,
BizQcItemParam.del_flag == 0,
).update(
{"del_flag": 1, "update_by": user_id, "update_time": now},
synchronize_session="fetch",
)
db.flush()
for idx, p in enumerate(params):
def build_row(row_id):
return BizQcItemParam(
id=row_id,
item_source=item_source,
item_id=item_id,
param_name="",
param_code="",
param_type="text",
required_flag="0",
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def apply_row(row, p, idx, _now):
param_type = (p.get("paramType") or "text").strip()
options_val = None
default_val = None
@@ -99,28 +104,32 @@ def save_params_for_item(
opts = _parse_options_list(p.get("options"))
options_val = json.dumps(opts, ensure_ascii=False)
default_val = opts[0] if opts else None
row.param_name = p.get("paramName", "")
row.param_code = p.get("paramCode", "")
row.param_unit = p.get("paramUnit") or None
row.param_type = param_type
row.options = options_val
row.default_value = default_val
row.required_flag = p.get("requiredFlag", "0")
row.order_num = p.get("orderNum", idx)
obj = BizQcItemParam(
id=generate_uuid(),
item_source=item_source,
item_id=item_id,
param_name=p.get("paramName", ""),
param_code=p.get("paramCode", ""),
param_unit=p.get("paramUnit") or None,
param_type=param_type,
options=options_val,
default_value=default_val,
required_flag=p.get("requiredFlag", "0"),
tenant_no=tenant_no,
order_num=p.get("orderNum", idx),
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(obj)
sync_child_rows(
db,
BizQcItemParam,
parent_attr="item_id",
parent_id=item_id,
tenant_no=tenant_no,
items=params,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda item: (item.get("paramCode") or "").strip(),
row_natural_key=lambda row: (row.param_code or "").strip(),
duplicate_message="参数编码重复",
foreign_message="参数不属于当前检测项目",
extra_filters={"item_source": item_source},
reuse_deleted=True,
)
db.commit()
return list_params_by_item(db, item_source, item_id, tenant_no)
+82 -54
View File
@@ -9,6 +9,7 @@ from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.crud.qc_material_stock_batch import _dec, _fmt_date, _fmt_dt, _parse_date, _require_str
from app.models.models import SysUser
from app.models.qc_material import (
@@ -439,6 +440,44 @@ def _resolve_unused_item_fields(
return out
def _unused_line_key(usage_id: Optional[str], batch_id: Optional[str]) -> str:
usage = (usage_id or "").strip()
if usage:
return f"usage:{usage}"
batch = (batch_id or "").strip()
if batch:
return f"batch:{batch}"
return ""
def _prepare_unused_item(db: Session, it: Dict[str, Any], tenant_no: str) -> Dict[str, Any]:
it = _resolve_unused_item_fields(db, it, tenant_no)
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
if qty is None or Decimal(str(qty)) <= 0:
raise ValueError("未用完数量必须大于0")
unit_price = it.get("unit_price") if it.get("unit_price") is not None else it.get("unitPrice")
return {
"id": str(it.get("id") or "").strip(),
"usage_id": (it.get("usage_id") or it.get("usageId") or "").strip() or None,
"batch_id": (it.get("batch_id") or it.get("batchId") or "").strip() or None,
"material_code": (it.get("material_code") or it.get("materialCode") or "").strip() or None,
"material_name": name,
"spec_model": (it.get("spec_model") or it.get("specModel") or "").strip() or None,
"unit": (it.get("unit") or "").strip() or None,
"stock_in_no": (it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
"stock_in_date": _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate")),
"expire_date": _parse_optional_date(it.get("expire_date") or it.get("expireDate")),
"catalog_no": (it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
"batch_no": (it.get("batch_no") or it.get("batchNo") or "").strip() or None,
"manufacturer": (it.get("manufacturer") or "").strip() or None,
"unit_price": Decimal(str(unit_price)) if unit_price is not None else None,
"holder": (it.get("holder") or "").strip() or None,
"unused_qty": Decimal(str(qty)),
"remark": (it.get("remark") or "").strip() or None,
}
def save_unused_lines(
db: Session,
check_id: str,
@@ -455,72 +494,61 @@ def save_unused_lines(
raise ValueError("仅进行中的盘点单可登记未用完物料")
now = datetime.now()
old_rows = (
db.query(BizQcMaterialStockCheckUnusedLine)
.filter(
BizQcMaterialStockCheckUnusedLine.check_id == check_id,
BizQcMaterialStockCheckUnusedLine.del_flag == 0,
)
.all()
)
for r in old_rows:
r.del_flag = 1
r.update_by = user_id
r.update_time = now
db.add(r)
prepared = [_prepare_unused_item(db, it, tenant_no) for it in (items or [])]
count = 0
for it in items or []:
it = _resolve_unused_item_fields(db, it, tenant_no)
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
if qty is None or Decimal(str(qty)) <= 0:
raise ValueError("未用完数量必须大于0")
qty_dec = Decimal(str(qty))
stock_in_date = _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate"))
expire_date = _parse_optional_date(it.get("expire_date") or it.get("expireDate"))
usage_id = (it.get("usage_id") or it.get("usageId") or "").strip() or None
batch_id = (it.get("batch_id") or it.get("batchId") or "").strip() or None
row = BizQcMaterialStockCheckUnusedLine(
id=generate_uuid(),
def build_row(row_id: str):
return BizQcMaterialStockCheckUnusedLine(
id=row_id,
check_id=check_id,
usage_id=usage_id,
batch_id=batch_id,
material_code=(it.get("material_code") or it.get("materialCode") or "").strip() or None,
material_name=name,
spec_model=(it.get("spec_model") or it.get("specModel") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
stock_in_no=(it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
stock_in_date=stock_in_date,
expire_date=expire_date,
catalog_no=(it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
batch_no=(it.get("batch_no") or it.get("batchNo") or "").strip() or None,
manufacturer=(it.get("manufacturer") or "").strip() or None,
unit_price=(
Decimal(str(it.get("unit_price") or it.get("unitPrice")))
if (it.get("unit_price") is not None or it.get("unitPrice") is not None)
else None
),
holder=(it.get("holder") or "").strip() or None,
unused_qty=qty_dec,
material_name="",
unused_qty=0,
tenant_no=tenant_no,
remark=(it.get("remark") or "").strip() or None,
create_by=user_id,
create_dept_id=dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(row)
count += 1
def apply_row(row, it, idx, _now):
row.usage_id = it["usage_id"]
row.batch_id = it["batch_id"]
row.material_code = it["material_code"]
row.material_name = it["material_name"]
row.spec_model = it["spec_model"]
row.unit = it["unit"]
row.stock_in_no = it["stock_in_no"]
row.stock_in_date = it["stock_in_date"]
row.expire_date = it["expire_date"]
row.catalog_no = it["catalog_no"]
row.batch_no = it["batch_no"]
row.manufacturer = it["manufacturer"]
row.unit_price = it["unit_price"]
row.holder = it["holder"]
row.unused_qty = it["unused_qty"]
row.remark = it["remark"]
row.order_num = idx
saved = sync_child_rows(
db,
BizQcMaterialStockCheckUnusedLine,
parent_attr="check_id",
parent_id=check_id,
tenant_no=tenant_no,
items=prepared,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: _unused_line_key(it.get("usage_id"), it.get("batch_id")),
row_natural_key=lambda row: _unused_line_key(row.usage_id, row.batch_id),
duplicate_message="未用完明细重复",
foreign_message="未用完明细不属于当前盘点单",
)
check.update_by = user_id
check.update_time = now
db.add(check)
db.commit()
return count
return len(saved)
def complete_check(
+40 -23
View File
@@ -5,8 +5,8 @@ from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud import qc_item_param as param_svc
from app.crud.child_row_sync import sync_child_rows
from app.crud.qc_task import get_task
from app.models.models import BizQcWorkOrderLabItem, BizQcWorkOrderThirdItem
from app.models.qc_param import BizQcTaskResult
@@ -152,36 +152,53 @@ def save_results_for_task(
if not task:
raise ValueError("任务不存在")
now = datetime.now()
db.query(BizQcTaskResult).filter(
BizQcTaskResult.task_id == task_id,
BizQcTaskResult.tenant_no == tenant_no,
BizQcTaskResult.del_flag == 0,
).update(
{"del_flag": 1, "update_by": user_id, "update_time": now},
synchronize_session="fetch",
)
db.flush()
for res in results:
obj = BizQcTaskResult(
id=generate_uuid(),
def _result_key(item: Dict[str, Any]) -> str:
param_id = (item.get("paramId") or "").strip()
if param_id:
return "id:" + param_id
return "code:" + (item.get("paramCode") or "").strip()
def build_row(row_id):
return BizQcTaskResult(
id=row_id,
task_id=task_id,
param_id=res.get("paramId") or None,
param_name=res.get("paramName", ""),
param_code=res.get("paramCode") or None,
param_unit=res.get("paramUnit") or None,
param_type=res.get("paramType", "text"),
param_value=res.get("paramValue") or None,
param_name="",
param_type="text",
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(obj)
def apply_row(row, res, _idx, _now):
row.param_id = res.get("paramId") or None
row.param_name = res.get("paramName", "")
row.param_code = res.get("paramCode") or None
row.param_unit = res.get("paramUnit") or None
row.param_type = res.get("paramType", "text")
row.param_value = res.get("paramValue") or None
sync_child_rows(
db,
BizQcTaskResult,
parent_attr="task_id",
parent_id=task_id,
tenant_no=tenant_no,
items=results,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=_result_key,
row_natural_key=lambda row: (
"id:" + row.param_id.strip()
if (row.param_id or "").strip()
else "code:" + (row.param_code or "").strip()
),
duplicate_message="检测结果重复",
foreign_message="检测结果不属于当前任务",
)
if commit:
db.commit()
else:
+51 -4
View File
@@ -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():
+267 -114
View File
@@ -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 (
@@ -24,6 +25,8 @@ from app.crud.collection_protocol import (
_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,
@@ -46,6 +49,8 @@ from app.models.models import (
BizProtocolPartyCustomer,
BizProtocolSample,
BizProtocolSampleTransport,
BizProductionWorkOrder,
BizQcSmsNotification,
BizQcWorkOrder,
BizSampleReception,
SysUser,
@@ -1249,7 +1254,7 @@ def update_donation(
commit: bool = True,
sync_customers: bool = True,
) -> BizResearchDonation:
"""更新科研捐赠单:主表字段 + 子资源 upsert(软删旧 + 创建新)。"""
"""更新科研捐赠单:主表字段就地更新,子资源按行同步。"""
obj = (
db.query(BizResearchDonation)
.filter(
@@ -1305,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(
@@ -1325,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,
@@ -1344,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
@@ -1358,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,
)
@@ -2226,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,
@@ -2235,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 = [
@@ -2290,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 = [
@@ -2381,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,
@@ -2391,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,
@@ -2412,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")}
@@ -2457,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,
@@ -2468,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
@@ -2498,30 +2635,46 @@ def _create_transport(
else:
express_fee = None
obj = BizResearchDonationSampleTransport(
existing = (
db.query(BizResearchDonationSampleTransport)
.filter(
BizResearchDonationSampleTransport.research_donation_id == donation_id,
BizResearchDonationSampleTransport.sample_id == sample_id,
BizResearchDonationSampleTransport.tenant_no == tenant_no,
BizResearchDonationSampleTransport.del_flag == 0,
)
.order_by(BizResearchDonationSampleTransport.create_time.asc())
.all()
)
obj = existing[0] if existing else BizResearchDonationSampleTransport(
id=generate_uuid(),
research_donation_id=donation_id,
sample_id=sample_id,
sample_code=sample_code or None,
transport_type=transport_type,
express_company_id=(tnorm.get("express_company_id") or "").strip() or None,
waybill_no=(tnorm.get("waybill_no") or "").strip() or None,
dest_site_id=(tnorm.get("dest_site_id") or "").strip() or None,
express_fee=express_fee,
eta_time=eta_time,
carrier_name=(tnorm.get("carrier_name") or "").strip() or None,
carrier_phone=(tnorm.get("carrier_phone") or "").strip() or None,
tenant_no=tenant_no,
order_num=order_num,
remark=(tnorm.get("remark") or "").strip() or None,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
obj.sample_code = sample_code or None
obj.transport_type = transport_type
obj.express_company_id = (tnorm.get("express_company_id") or "").strip() or None
obj.waybill_no = (tnorm.get("waybill_no") or "").strip() or None
obj.dest_site_id = (tnorm.get("dest_site_id") or "").strip() or None
obj.express_fee = express_fee
obj.eta_time = eta_time
obj.carrier_name = (tnorm.get("carrier_name") or "").strip() or None
obj.carrier_phone = (tnorm.get("carrier_phone") or "").strip() or None
obj.order_num = order_num
obj.remark = (tnorm.get("remark") or "").strip() or None
obj.del_flag = 0
obj.update_by = user_id
obj.update_time = now
db.add(obj)
for extra in existing[1:]:
extra.del_flag = 1
extra.update_by = user_id
extra.update_time = now
db.add(extra)
db.flush()
return obj
+49 -43
View File
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows, sync_link_rows
from app.models.models import (
SysPost,
SysPostRole,
@@ -377,26 +378,18 @@ def update_role_menus(
tenant_no: str,
user_id: Optional[str] = None,
) -> None:
# uk_role_menu 为 (role_id, menu_id, del_flag):先全量软删再插入会在再次保存时
# 出现两条 (role_id, menu_id, del_flag=1),触发 1062。关联表采用整角色物理清理后重建。
unique_ids = list(dict.fromkeys(menu_ids or []))
db.query(SysRoleMenu).filter(SysRoleMenu.role_id == role_id).delete(synchronize_session=False)
now = datetime.now()
for mid in unique_ids:
obj = SysRoleMenu(
id=generate_uuid(),
role_id=role_id,
menu_id=mid,
tenant_no=tenant_no,
order_num=0,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(obj)
sync_link_rows(
db,
SysRoleMenu,
parent_attr="role_id",
parent_id=role_id,
link_attr="menu_id",
link_ids=menu_ids,
tenant_no=tenant_no,
user_id=user_id,
duplicate_message="菜单重复",
foreign_message="菜单关联不属于当前角色",
)
db.commit()
@@ -430,28 +423,41 @@ def update_role_data_scope(
obj.update_time = datetime.now()
db.add(obj)
# soft-delete old scope details
db.query(SysRoleDataScopeDetail).filter(
and_(SysRoleDataScopeDetail.role_id == role_id, SysRoleDataScopeDetail.del_flag == 0)
).update({"del_flag": 1, "update_time": datetime.now()})
now = datetime.now()
details = scope_details if data_scope == "5" and scope_details else []
# insert new scope details (only when custom data_scope == '5')
if data_scope == "5" and scope_details:
now = datetime.now()
for detail in scope_details:
sd = SysRoleDataScopeDetail(
id=generate_uuid(),
role_id=role_id,
scope_type=detail.get("scopeType", "dept"),
scope_value=detail.get("scopeValue", ""),
tenant_no=tenant_no,
order_num=0,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(sd)
def build_scope(row_id):
return SysRoleDataScopeDetail(
id=row_id,
role_id=role_id,
scope_type="",
scope_value="",
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def apply_scope(row, detail, _idx, _now):
row.scope_type = detail.get("scopeType", "dept")
row.scope_value = detail.get("scopeValue", "")
row.order_num = 0
sync_child_rows(
db,
SysRoleDataScopeDetail,
parent_attr="role_id",
parent_id=role_id,
tenant_no=tenant_no,
items=details,
user_id=user_id,
now=now,
build_row=build_scope,
apply_fields=apply_scope,
natural_key=lambda detail: f"{detail.get('scopeType', 'dept')}:{detail.get('scopeValue', '')}",
row_natural_key=lambda row: f"{row.scope_type}:{row.scope_value}",
duplicate_message="数据权限重复",
foreign_message="数据权限不属于当前角色",
reuse_deleted=True,
)
db.commit()
+1 -1
View File
@@ -23,7 +23,7 @@ def _resolve_issued_user_display_name(
uid = (user_id or "").strip()
if not uid:
return None
u = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first()
u = db.query(SysUser).filter(SysUser.id == uid).first()
if not u:
return None
return (u.nick_name or u.user_no or "").strip() or None
+18 -1
View File
@@ -1230,12 +1230,29 @@ def apply_freight_on_protocol_save(
if keep_count != stale_count:
raise ValueError("已结算的运费不能调整样本")
settled_relink_ids.add(freight_id)
keep_sample_ids = set()
for idx, sample_obj in enumerate(saved_samples or []):
if idx >= len(input_samples or []):
break
raw = input_samples[idx] if isinstance(input_samples[idx], dict) else {}
sample_id = str(getattr(sample_obj, "id", None) or "").strip()
if not sample_id:
continue
draft = raw.get("freightDraft")
if draft is None:
draft = raw.get("freight_draft")
if _input_freight_id(raw) or isinstance(draft, dict):
keep_sample_ids.add(sample_id)
removed_freight_item = False
for stale in stale_items:
if stale.sample_id in keep_sample_ids:
continue
stale.del_flag = 1
stale.update_by = user_id
stale.update_time = now
db.add(stale)
if stale_items:
removed_freight_item = True
if removed_freight_item:
db.flush()
draft_groups: Dict[str, Dict[str, Any]] = {}
-6
View File
@@ -1131,12 +1131,6 @@ def update_sample_reception(
rec.update_by = user_id
rec.update_time = datetime.now()
check_result_svc.soft_delete_reception_check_results(
db,
reception_id=reception_id,
tenant_no=tenant_no,
user_id=user_id,
)
check_result_svc.save_reception_check_results(
db,
reception_id=reception_id,
@@ -7,8 +7,8 @@ from typing import Any, Dict, List, Optional
from sqlalchemy import and_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud import sample_reception_check_item as check_item_svc
from app.crud.child_row_sync import sync_child_rows
from app.models.models import (
BizSampleReception,
BizSampleReceptionCheckItem,
@@ -105,7 +105,7 @@ def soft_delete_reception_check_results(
tenant_no: str,
user_id: Optional[str],
) -> None:
"""软删除某次接收的全部检查项结果(编辑接收时先清空再重写)。"""
"""软删除某次接收的全部检查项结果。删除接收记录时使用。"""
now = datetime.now()
(
db.query(BizSampleReceptionCheckResult)
@@ -143,26 +143,56 @@ def save_reception_check_results(
if unknown:
raise ValueError("存在无效的检查项")
now = datetime.now()
payload = []
for item in active_items:
item_id = str(item["id"])
checked = submitted.get(item_id, False)
row = BizSampleReceptionCheckResult(
id=generate_uuid(),
payload.append(
{
"check_item_id": item_id,
"item_code": item["itemCode"],
"item_name": item["itemName"],
"checked": 1 if submitted.get(item_id, False) else 0,
"order_num": item.get("orderNum") or 0,
}
)
def build_row(row_id):
return BizSampleReceptionCheckResult(
id=row_id,
reception_id=reception_id,
check_item_id=item_id,
item_code=item["itemCode"],
item_name=item["itemName"],
checked=1 if checked else 0,
check_item_id="",
item_code="",
item_name="",
checked=0,
tenant_no=tenant_no,
order_num=item.get("orderNum") or 0,
create_by=user_id,
create_dept_id=create_dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(row)
def apply_row(row, item, _idx, _now):
row.check_item_id = item["check_item_id"]
row.item_code = item["item_code"]
row.item_name = item["item_name"]
row.checked = item["checked"]
row.order_num = item["order_num"]
sync_child_rows(
db,
BizSampleReceptionCheckResult,
parent_attr="reception_id",
parent_id=reception_id,
tenant_no=tenant_no,
items=payload,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda item: item["check_item_id"],
row_natural_key=lambda row: row.check_item_id or "",
duplicate_message="检查项重复",
foreign_message="检查结果不属于当前接收记录",
)
def sync_legacy_columns_from_results(
+16 -3
View File
@@ -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
View File
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_link_rows
from app.crud.base import CRUDBase
from app.models.models import SysUser, SysUserRole, SysUserPost, SysDept
@@ -179,29 +180,19 @@ def set_user_post_ids_only(
post_ids: List[str],
updater_id: Optional[str],
) -> None:
"""仅替换 sys_user_post,不影响 sys_user_role。"""
now = datetime.now()
uniq = [p for p in dict.fromkeys(post_ids or []) if p]
db.query(SysUserPost).filter(
and_(SysUserPost.user_id == user_id, SysUserPost.del_flag == 0)
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
for pid in uniq:
db.add(
SysUserPost(
id=generate_uuid(),
user_id=user_id,
post_id=pid,
tenant_no=tenant_no,
order_num=0,
remark=None,
create_by=updater_id,
create_dept_id="",
create_time=now,
update_by=updater_id,
update_time=now,
del_flag=0,
)
)
"""按职位更新 sys_user_post,不影响 sys_user_role。"""
sync_link_rows(
db,
SysUserPost,
parent_attr="user_id",
parent_id=user_id,
link_attr="post_id",
link_ids=post_ids,
tenant_no=tenant_no,
user_id=updater_id,
duplicate_message="职位重复",
foreign_message="职位关联不属于当前用户",
)
def list_user_ids_bound_to_post(db: Session, post_id: str, tenant_no: str) -> List[str]:
@@ -514,51 +505,33 @@ def _replace_associations(
role_ids: Optional[List[str]] = None,
post_ids: Optional[List[str]] = None,
) -> None:
"""全量替换关联。仅当对应参数不为 None 时才替换该侧(避免只改职位时清空直接角色)。"""
now = datetime.now()
"""按关联 id 更新。仅当对应参数不为 None 时才替换该侧(避免只改职位时清空直接角色)。"""
if role_ids is not None:
db.query(SysUserRole).filter(
and_(SysUserRole.user_id == user_id, SysUserRole.del_flag == 0)
).update({"del_flag": 1, "update_time": now})
for rid in role_ids:
if not rid:
continue
db.add(SysUserRole(
id=generate_uuid(),
user_id=user_id,
role_id=rid,
tenant_no=tenant_no,
order_num=0,
remark=None,
create_by=creator_id,
create_dept_id="",
create_time=now,
update_by=creator_id,
update_time=now,
del_flag=0,
))
sync_link_rows(
db,
SysUserRole,
parent_attr="user_id",
parent_id=user_id,
link_attr="role_id",
link_ids=role_ids,
tenant_no=tenant_no,
user_id=creator_id,
duplicate_message="角色重复",
foreign_message="角色关联不属于当前用户",
)
if post_ids is not None:
db.query(SysUserPost).filter(
and_(SysUserPost.user_id == user_id, SysUserPost.del_flag == 0)
).update({"del_flag": 1, "update_time": now})
for pid in post_ids:
if not pid:
continue
db.add(SysUserPost(
id=generate_uuid(),
user_id=user_id,
post_id=pid,
tenant_no=tenant_no,
order_num=0,
remark=None,
create_by=creator_id,
create_dept_id="",
create_time=now,
update_by=creator_id,
update_time=now,
del_flag=0,
))
sync_link_rows(
db,
SysUserPost,
parent_attr="user_id",
parent_id=user_id,
link_attr="post_id",
link_ids=post_ids,
tenant_no=tenant_no,
user_id=creator_id,
duplicate_message="职位重复",
foreign_message="职位关联不属于当前用户",
)
def _parse_hire_date(value: Optional[Union[str, date]]) -> Optional[date]:
+40
View File
@@ -0,0 +1,40 @@
"""历史单据上的人员姓名。
用户管理里删除人员只会把 sys_user.del_flag 置为 1,账号不能再登录,
也不能再被选为新的负责人。已经写在工单、步骤、协议上的 user_id
仍要显示 nick_name;查不到人时调用方再回退成 id。
选人、校验、登录不要用本模块。
"""
from __future__ import annotations
from typing import Dict, Iterable, List
from sqlalchemy.orm import Session
from app.models.models import SysUser
def user_display_name_map(db: Session, user_ids: Iterable[str]) -> Dict[str, str]:
"""按 user_id 取展示名,包含已删除用户。优先昵称,否则账号。"""
ids: List[str] = []
seen = set()
for raw in user_ids or []:
uid = str(raw or "").strip()
if not uid or uid in seen:
continue
seen.add(uid)
ids.append(uid)
if not ids:
return {}
rows = (
db.query(SysUser.id, SysUser.nick_name, SysUser.user_no)
.filter(SysUser.id.in_(ids))
.all()
)
out: Dict[str, str] = {}
for row in rows:
name = (row.nick_name or "").strip() or (row.user_no or "").strip()
if name:
out[row.id] = name
return out
+54 -1
View File
@@ -31,6 +31,43 @@ MarketContractAmount = condecimal(
)
MarketSampleTypeId = constr(min_length=1, max_length=64)
_CUSTOMER_INT_LABELS = {
'birth_order': '胎次',
'newborn_weight_gram': '新生儿体重',
}
def _blank_to_none(value):
"""移动端数字框清空后会提交 "",可选数字应视为未填。"""
if isinstance(value, str):
text = value.strip()
return None if text == '' else text
return value
def _optional_customer_int(value, field_name: str):
if value is None or isinstance(value, bool):
return value
if isinstance(value, int):
return value
label = _CUSTOMER_INT_LABELS[field_name]
if isinstance(value, float):
if value.is_integer():
return int(value)
raise ValueError(f'{label}须为整数')
if isinstance(value, str):
text = value.strip().replace(',', '')
if text == '':
return None
if re.fullmatch(r'[+-]?\d+\.?', text):
return int(text.rstrip('.'))
if re.fullmatch(r'[+-]?\d+\.\d+', text):
number = Decimal(text)
if number == number.to_integral_value():
return int(number)
raise ValueError(f'{label}须为整数')
return value
class MarketAttachmentDownloadBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
@@ -193,6 +230,16 @@ class MarketProtocolCustomerBody(BaseModel):
collector_user_id: Optional[str] = Field(None, alias='collectorUserId')
remark: Optional[str] = None
@field_validator('birth_order', 'newborn_weight_gram', mode='before')
@classmethod
def normalize_optional_int(cls, value, info):
return _optional_customer_int(value, info.field_name)
@field_validator('apgar_1min', 'apgar_5min', mode='before')
@classmethod
def normalize_optional_float(cls, value):
return _blank_to_none(value)
class MarketProtocolCollectionBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
@@ -390,11 +437,17 @@ class MarketProtocolPaymentDetailBody(BaseModel):
class MarketProtocolBody(BaseModel):
"""移动协议白名单;状态、审核、财务、退款及转换字段一律拒绝。"""
"""移动协议白名单。协议/捐赠状态可改;审核、财务、退款及转换时间字段一律拒绝。"""
model_config = ConfigDict(extra='forbid', populate_by_name=True)
source: Literal['cell_storage', 'research_donation']
protocol_status: Optional[
Literal['dsx', 'sxz', 'ygq', 'yzf', 'sdfq', 'zky']
] = Field(None, alias='protocolStatus')
donation_status: Optional[
Literal['dsx', 'sxz', 'ygq', 'yzf', 'sdfq', 'zckc']
] = Field(None, alias='donationStatus')
protocol_no: Optional[str] = Field(None, alias='protocolNo')
donation_no: Optional[str] = Field(None, alias='donationNo')
register_date: Optional[str] = Field(None, alias='registerDate')
+4
View File
@@ -63,6 +63,7 @@ class StepReorderBody(BaseModel):
# Step sub-table items
class StepReagentItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
reagent_code: Optional[str] = Field(None, alias="reagentCode")
reagent_name: Optional[str] = Field(None, alias="reagentName")
spec: Optional[str] = None
@@ -75,6 +76,7 @@ class StepReagentItem(BaseModel):
class StepConsumableItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
consumable_code: Optional[str] = Field(None, alias="consumableCode")
consumable_name: Optional[str] = Field(None, alias="consumableName")
spec_model: Optional[str] = Field(None, alias="specModel")
@@ -87,6 +89,7 @@ class StepConsumableItem(BaseModel):
class StepOutputItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
output_type_id: Optional[str] = Field(None, alias="outputTypeId")
output_name: Optional[str] = Field(None, alias="outputName")
output_barcode: Optional[str] = Field(None, alias="outputBarcode")
@@ -96,6 +99,7 @@ class StepOutputItem(BaseModel):
class StepDataCollectionItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
collection_name: Optional[str] = Field(None, alias="collectionName")
unit: Optional[str] = None
order_num: Optional[int] = Field(0, alias="orderNum")
+4
View File
@@ -88,6 +88,7 @@ class TaskStepUpdate(BaseModel):
class TaskStepMaterialItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
material_code: Optional[str] = None
material_name: Optional[str] = None
spec: Optional[str] = None
@@ -102,6 +103,7 @@ class TaskStepMaterialItem(BaseModel):
class TaskStepOutputItem(BaseModel):
id: Optional[str] = None
output_type_id: Optional[str] = None
output_name: Optional[str] = None
output_barcode: Optional[str] = None
@@ -109,6 +111,7 @@ class TaskStepOutputItem(BaseModel):
class TaskStepDataCollectionItem(BaseModel):
id: Optional[str] = None
collection_name: Optional[str] = None
unit: Optional[str] = None
collection_value: Optional[str] = None
@@ -117,6 +120,7 @@ class TaskStepDataCollectionItem(BaseModel):
class TaskInputItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
input_type: str = Field(..., alias="inputType")
input_id: Optional[str] = Field(None, alias="inputId")
input_code: Optional[str] = Field(None, alias="inputCode")
+13 -6
View File
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from app.core.utils import parse_datetime
from app.core.protocol_status_display import (
assert_protocol_status_transition,
build_void_type_label_map,
resolve_void_type_label,
)
@@ -1424,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()
)
@@ -1445,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()
)
@@ -1870,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(
@@ -1951,8 +1950,8 @@ def create_protocol(
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'
@@ -2073,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(
@@ -2214,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,
@@ -2227,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:
+2 -2
View File
@@ -2237,7 +2237,7 @@ def _apply_instance_list_filters(
if not joined_initiator:
q = q.outerjoin(
SysUser,
(SysUser.id == OaInstance.initiator_user_id) & (SysUser.del_flag == 0),
SysUser.id == OaInstance.initiator_user_id,
)
joined_initiator = True
like = f"%{combined}%"
@@ -2265,7 +2265,7 @@ def _apply_instance_list_filters(
if not joined_initiator:
q = q.outerjoin(
SysUser,
(SysUser.id == OaInstance.initiator_user_id) & (SysUser.del_flag == 0),
SysUser.id == OaInstance.initiator_user_id,
)
joined_initiator = True
q = q.filter(SysUser.nick_name.like(f"%{iname}%"))
+2 -10
View File
@@ -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
File diff suppressed because it is too large. Load diff
+104 -112
View File
@@ -1,11 +1,11 @@
# Graph Report - app (2026-10-26)
# Graph Report - app (2026-10-28)
## Corpus Check
- Large corpus: 317 files · ~388,657 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
- 6522 nodes · 36161 edges · 48 communities detected
- Extraction: 32% EXTRACTED · 68% INFERRED · 0% AMBIGUOUS · INFERRED: 24740 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,22 +48,20 @@
- [[_COMMUNITY_Community 36|Community 36]]
- [[_COMMUNITY_Community 37|Community 37]]
- [[_COMMUNITY_Community 38|Community 38]]
- [[_COMMUNITY_Community 39|Community 39]]
- [[_COMMUNITY_Community 40|Community 40]]
- [[_COMMUNITY_Community 41|Community 41]]
- [[_COMMUNITY_Community 42|Community 42]]
- [[_COMMUNITY_Community 43|Community 43]]
- [[_COMMUNITY_Community 44|Community 44]]
- [[_COMMUNITY_Community 45|Community 45]]
- [[_COMMUNITY_Community 46|Community 46]]
- [[_COMMUNITY_Community 47|Community 47]]
- [[_COMMUNITY_Community 48|Community 48]]
- [[_COMMUNITY_Community 51|Community 51]]
- [[_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
@@ -73,253 +71,247 @@
## Surprising Connections (you probably didn't know these)
- `统一记录 API 请求/响应出入参的中间件` --uses--> `SysApiRequestLog` [INFERRED]
core/api_request_log_middleware.py → models/models.py
- `get_menu_list()` --calls--> `success_response()` [INFERRED]
api/menu.py → core/response.py
- `菜单 code 权限守卫(V0.3:通过 sys_user_role → sys_role_menu → sys_menu.perms)` --uses--> `SysUser` [INFERRED]
core/menu_guard.py → models/models.py
- `运营端角色守卫:admin / super_admin 可访问。` --uses--> `SysUser` [INFERRED]
core/menu_guard.py → models/models.py
- `读取用户授权的菜单权限标识集合(与前端 meta.menuCode / perms 对齐)。` --uses--> `SysUser` [INFERRED]
core/menu_guard.py → models/models.py
- `接口菜单权限守卫(已统一放行 menuCode 校验,仅保留运营端登录)。` --uses--> `SysUser` [INFERRED]
core/menu_guard.py → models/models.py
## Communities
### Community 0 - "Community 0"
Cohesion: 0.01
Nodes (1156): _assert_user_tenant_access(), attachment_download_url(), create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu() (+1148 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 (766): _save_sample_payments(), BaseSettings, Config, secret_key_must_be_strong(), Settings, generate_uuid(), allocate_next_biz_no(), _allocate_sqlite_sequence() (+758 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.04
Nodes (797): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+789 more)
Cohesion: 0.01
Nodes (615): GarmentBatchDeleteBody, GarmentBatchDiscardBody, GarmentCreateBody, GarmentDiscardBody, GarmentUpdateBody, SterilizeLogBatchBody, SterilizeLogCreateBody, SterilizeLogUpdateBody (+607 more)
### Community 3 - "Community 3"
Cohesion: 0.01
Nodes (730): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+722 more)
Cohesion: 0.04
Nodes (495): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+487 more)
### Community 4 - "Community 4"
Cohesion: 0.01
Nodes (451): _normalize_date_bound(), assert_protocol_sensitive_fields(), _attach_handler_name(), complete_protocol_invoice_request(), create_collection_protocol(), delete_collection_protocol(), _draft_fields_from_body(), get_collection_protocol_detail() (+443 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.02
Nodes (326): 截止日期:仅日期时扩到当天 23:59:59。, 发起前预览审批链(条件齐后解析最终路径与审批人)。, 兼容旧 GET:无 formData,含条件分支时多为待确定占位。, _app_to_dict(), _copied_app_name(), copy_app(), create_app(), _create_version() (+318 more)
Cohesion: 0.08
Nodes (412): 环境检测 — 检测项维护 + 登记管理 API, InsuranceCompanyCreateBody, InsuranceCompanyInvoiceCreateBody, InsuranceCompanyInvoiceUpdateBody, InsuranceCompanyUpdateBody, InsuranceProjectPriceCreateBody, InsuranceProjectPriceUpdateBody, 保险公司及险种价格管理 API(保险公司 + 开票信息 + 险种价格) (+404 more)
### Community 6 - "Community 6"
Cohesion: 0.02
Nodes (276): eq_unicode(), like_unicode(), MySQL 字符串比较/模糊匹配时统一排序规则,避免 Illegal mix of collations。, 跨表字符串等值比较(JOIN / WHERE)时统一为 utf8mb4_unicode_ci。, 将列显式设为 utf8mb4_unicode_ci。, unicode_ci(), 解析科研捐赠质检列表的协议状态筛选语义。 Returns: ("in", statuses) — 按所选状态 IN 过滤, resolve_research_donation_protocol_status_filter() (+268 more)
Nodes (359): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+351 more)
### Community 7 - "Community 7"
Cohesion: 0.02
Nodes (170): _register_attachment_routes(), _register_freight_routes(), _register_payment_routes(), _register_presign_routes(), _register_protocol_routes(), 小程序鉴权:微信登录 / 绑定 openid。, 当前用户是否已绑定微信服务号 openid。, 服务号网页授权 code 绑定当前用户(需 Bearer)。 (+162 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.02
Nodes (150): get_protocol_nos_used_in_protocols(), get_sample_codes_used_in_protocol_samples(), _cell_row_to_dict(), _enrich_cell_dicts_with_live_status(), _find_outbound_cell_row(), _find_outbound_qc_material_row(), _fmt_dt(), get_outbound_material() (+142 more)
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 (156): assert_protocol_status_transition(), build_void_type_label_map(), format_protocol_status_label(), 状态流转被拒绝时的提示,状态码展示为中文名称。, resolve_void_type_label(), status_transition_denied_message(), _ancestor_link_from_cell_product_input(), _ancestor_sample_chain_link() (+148 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.04
Nodes (130): _batch_line_to_dict(), _unused_amount(), _unused_line_to_dict(), _batch_map_for_prep_lines(), cancel_prep(), complete_prep(), _create_auto_stock_out(), create_prep() (+122 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 (113): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, list_storage_slot_stats(), OccupancyCreateBody (+105 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.03
Nodes (98): 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() (+90 more)
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.04
Nodes (100): _lookup_attachment_by_url(), _normalize_attachment_url_key(), urls_to_attachment_list(), _apply_qc_status_filter(), _base_query(), _derive_qc_conclusion(), _ensure_notification(), _eq_ci() (+92 more)
Cohesion: 0.05
Nodes (114): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, list_storage_slot_stats(), OccupancyCreateBody (+106 more)
### Community 14 - "Community 14"
Cohesion: 0.05
Nodes (99): deduct_for_step(), _display_remark(), list_stock_logs(), _parse_receiver_purpose_from_remark(), 耗材变动记录 CRUD(biz_consumable_stock_log), 撤销步骤此前 step_save 扣减,便于重新填报。, 扣减耗材库存并生成变动记录。调用方控制事务,本函数不 commit。, _resolve_receiver_purpose() (+91 more)
Cohesion: 0.04
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.05
Nodes (74): _fmt_dt(), get_handover_by_schedule(), get_outbound_handover(), get_outbound_handover_view(), _handover_row_to_dict(), _handover_view_dict(), _parse_handover_time(), 供输注计划 detail 聚合的交接只读信息。 (+66 more)
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.22
Nodes (74): create_delivery(), _deduct_fee_on_delivery(), delete_delivery_material(), _generate_delivery_no(), get_delivery_by_schedule(), get_delivery_method(), list_deliveries_by_application(), list_delivery_materials() (+66 more)
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.05
Nodes (57): _error_json(), lifespan(), 未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin, 数据库异常:返回 JSON 并保留 CORS 头,避免浏览器只报跨域错误, sqlalchemy_exception_handler(), unhandled_exception_handler(), BaseHTTPMiddleware, ApiCryptoError (+49 more)
Cohesion: 0.06
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.06
Nodes (44): get_designer_data(), list_params_by_item(), _parse_options_list(), _row_to_dict(), save_params_for_item(), _validate_param(), list_results_by_task(), _list_saved_results_by_task() (+36 more)
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 (29): _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() (+21 more)
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 21 - "Community 21"
Cohesion: 0.14
Nodes (22): 验证密码(哈希非 bcrypt 或损坏时返回 False,避免抛错), verify_password(), _attach_user_relations(), change_password(), create_user(), _empty_user_relation_fields(), enrich_user_dicts_with_relations(), get_user() (+14 more)
Cohesion: 0.19
Nodes (21): apply_overtime_kit_hours(), _build_rest_day_segments(), _build_workday_windows(), calc_overtime_hours(), _day_at_minutes(), _day_start_ms(), _extract_rules(), _format_ymd() (+13 more)
### Community 22 - "Community 22"
Cohesion: 0.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 23 - "Community 23"
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 24 - "Community 24"
Cohesion: 0.21
Nodes (19): _consumable_row_to_dict(), create_process(), _create_sub_consumable(), _create_sub_data_collection(), _create_sub_output(), _create_sub_reagent(), _data_collection_row_to_dict(), get_process() (+11 more)
### Community 25 - "Community 25"
### 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 26 - "Community 26"
Cohesion: 0.19
Nodes (18): _field_label(), _is_non_empty_str(), _is_required(), _is_valid_receive_account_item(), normalize_account_fields(), _pick(), _pick_order_num(), _pick_str() (+10 more)
### Community 24 - "Community 24"
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 27 - "Community 27"
### Community 25 - "Community 25"
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 28 - "Community 28"
### Community 26 - "Community 26"
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 29 - "Community 29"
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.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 30 - "Community 30"
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 31 - "Community 31"
### Community 28 - "Community 28"
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 32 - "Community 32"
### Community 29 - "Community 29"
Cohesion: 0.29
Nodes (2): BaseModel, SoftDeleteModel
### Community 33 - "Community 33"
### 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 34 - "Community 34"
### 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 35 - "Community 35"
### Community 32 - "Community 32"
Cohesion: 0.4
Nodes (5): batch_matches_stock_status(), normalize_batch_stock_status(), 质检物料入库批次:按剩余数量过滤的语义辅助。, 纯函数判定(供单测);list_batches 用 SQL 等价条件。, 返回 'available' | 'exhausted';全部 / 空 / 非法 → None(不加剩余数量条件)。
### Community 36 - "Community 36"
### Community 33 - "Community 33"
Cohesion: 0.5
Nodes (2): _auto_fill_create_dept_id(), ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID
### Community 37 - "Community 37"
### Community 34 - "Community 34"
Cohesion: 0.67
Nodes (2): get_menu_list(), 运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)
### Community 35 - "Community 35"
Cohesion: 0.67
Nodes (1): 转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。
### Community 38 - "Community 38"
### Community 36 - "Community 36"
Cohesion: 1.0
Nodes (1): 质检物料金额查看授权:专用角色与菜单权限常量。
### Community 39 - "Community 39"
### Community 37 - "Community 37"
Cohesion: 1.0
Nodes (1): 请求级别的用户上下文变量 通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传
### Community 40 - "Community 40"
### Community 38 - "Community 38"
Cohesion: 1.0
Nodes (1): API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)
### Community 43 - "Community 43"
### Community 41 - "Community 41"
Cohesion: 1.0
Nodes (1): 发送订单状态通知 Args: openid: 用户openid ord
### Community 44 - "Community 44"
### Community 42 - "Community 42"
Cohesion: 1.0
Nodes (1): 发送支付成功通知 Args: openid: 用户openid ord
### Community 45 - "Community 45"
### Community 43 - "Community 43"
Cohesion: 1.0
Nodes (1): 发送配送通知 Args: openid: 配送员openid task
### Community 46 - "Community 46"
### Community 44 - "Community 44"
Cohesion: 1.0
Nodes (1): 发送配送时效预警 Args: openid: 配送员openid ta
### Community 47 - "Community 47"
### Community 45 - "Community 45"
Cohesion: 1.0
Nodes (1): 发送退款结果通知 Args: openid: 用户openid ord
### Community 48 - "Community 48"
### Community 46 - "Community 46"
Cohesion: 1.0
Nodes (1): 发送催单通知给配送员 Args: deliver_openid: 配送员openid
### Community 51 - "Community 51"
### Community 49 - "Community 49"
Cohesion: 1.0
Nodes (1): 接受 YYYYMMDD / YYYY-MM-DD,统一规范为 CHAR(8) YYYYMMDD。
## Knowledge Gaps
- **210 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `严格判断当前租户用户是否拥有指定移动市场权限码。`, `为移动市场 API 提供租户化、精确匹配的菜单权限依赖。` (+205 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 32`** (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 36`** (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 37`** (3 nodes): `add_months_ymd()`, `oa_confirmation_date.py`, `转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。`
- **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 38`** (2 nodes): `qc_material_amount_auth_constants.py`, `质检物料金额查看授权:专用角色与菜单权限常量。`
- **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 39`** (2 nodes): `context.py`, `请求级别的用户上下文变量 通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
- **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 40`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
- **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 43`** (1 nodes): `发送订单状态通知 Args: openid: 用户openid ord`
- **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 44`** (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 45`** (1 nodes): `发送配送通知 Args: openid: 配送员openid task`
- **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 46`** (1 nodes): `发送配送时效预警 Args: openid: 配送员openid ta`
- **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 47`** (1 nodes): `发送退款结果通知 Args: openid: 用户openid ord`
- **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 48`** (1 nodes): `发送催单通知给配送员 Args: deliver_openid: 配送员openid`
- **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 51`** (1 nodes): `接受 YYYYMMDD / YYYY-MM-DD,统一规范为 CHAR(8) YYYYMMDD。`
- **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 3` to `Community 0`, `Community 1`, `Community 2`, `Community 4`, `Community 5`, `Community 7`, `Community 10`, `Community 11`, `Community 12`, `Community 14`, `Community 15`, `Community 16`, `Community 18`, `Community 21`, `Community 28`, `Community 30`?**
_High betweenness centrality (0.125) - this node is a cross-community bridge._
- **Why does `success_response()` connect `Community 0` to `Community 3`, `Community 4`, `Community 7`, `Community 11`, `Community 12`, `Community 18`?**
_High betweenness centrality (0.047) - this node is a cross-community bridge._
- **Why does `OaEngineError` connect `Community 5` to `Community 0`, `Community 1`, `Community 3`?**
_High betweenness centrality (0.035) - 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._
+28992 -23442
View File
File diff suppressed because it is too large. Load diff
+52 -49
View File
@@ -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": 1792995664.0257761,
"/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": 1792995679.137445,
"/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": 1792995655.626911,
"/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,13 +209,13 @@
"/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": 1793001629.236552,
@@ -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": 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": 1793001267.3348439,
"/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,
+208
View File
@@ -0,0 +1,208 @@
"""子表按行同步:更新原行、新增缺行、只软删去掉的行。"""
import unittest
from datetime import datetime
from sqlalchemy import Column, DateTime, Integer, SmallInteger, String, create_engine
from sqlalchemy.orm import sessionmaker
from app.core.database import Base
from app.crud.child_row_sync import sync_child_rows
class DemoChild(Base):
__tablename__ = "demo_child_row"
id = Column(String(64), primary_key=True)
parent_id = Column(String(64), nullable=False)
tenant_no = Column(String(64), nullable=False)
item_code = Column(String(64))
item_name = Column(String(128))
order_num = Column(Integer)
create_time = Column(DateTime)
update_by = Column(String(64))
update_time = Column(DateTime)
del_flag = Column(SmallInteger, nullable=False, default=0)
def _session():
engine = create_engine("sqlite://")
DemoChild.__table__.create(bind=engine)
return sessionmaker(bind=engine)()
def _row(**kwargs):
now = datetime(2026, 10, 27, 9, 0, 0)
data = dict(
parent_id="parent-1",
tenant_no="tenant-a",
item_code="R-1",
item_name="试剂",
order_num=0,
create_time=now,
update_time=now,
del_flag=0,
)
data.update(kwargs)
return DemoChild(**data)
def _sync(db, items):
now = datetime(2026, 10, 27, 10, 0, 0)
def build_row(row_id):
return DemoChild(
id=row_id,
parent_id="parent-1",
tenant_no="tenant-a",
create_time=now,
)
def apply_row(row, item, idx, _now):
row.item_code = item.get("item_code")
row.item_name = item.get("item_name")
row.order_num = idx
return sync_child_rows(
db,
DemoChild,
parent_attr="parent_id",
parent_id="parent-1",
tenant_no="tenant-a",
items=items,
user_id="user-1",
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda item: (item.get("item_code") or "").strip(),
row_natural_key=lambda row: (row.item_code or "").strip(),
)
class TestChildRowSync(unittest.TestCase):
def test_id_keeps_row_when_fields_change(self):
db = _session()
db.add(_row(id="row-keep", item_name="旧名称"))
db.commit()
saved = _sync(db, [{"id": "row-keep", "item_code": "R-2", "item_name": "新名称"}])
self.assertEqual(saved[0].id, "row-keep")
self.assertEqual(saved[0].item_name, "新名称")
self.assertEqual(db.query(DemoChild).filter_by(del_flag=0).count(), 1)
def test_natural_key_keeps_id_when_payload_omits_id(self):
db = _session()
db.add(_row(id="row-keep", item_code="R-1"))
db.commit()
saved = _sync(db, [{"item_code": "R-1", "item_name": "改名"}])
self.assertEqual([row.id for row in saved], ["row-keep"])
self.assertEqual(saved[0].item_name, "改名")
def test_insert_update_and_soft_delete_removed(self):
db = _session()
db.add(_row(id="row-keep", item_code="R-1"))
db.add(_row(id="row-drop", item_code="R-9", item_name="去掉"))
db.commit()
saved = _sync(
db,
[
{"id": "row-keep", "item_code": "R-1", "item_name": "留下"},
{"item_code": "R-NEW", "item_name": "新增"},
],
)
self.assertEqual(saved[0].id, "row-keep")
self.assertNotEqual(saved[1].id, "row-drop")
rows = {row.id: row for row in db.query(DemoChild).all()}
self.assertEqual(rows["row-keep"].del_flag, 0)
self.assertEqual(rows["row-drop"].del_flag, 1)
self.assertEqual(rows[saved[1].id].del_flag, 0)
def test_id_from_another_parent_is_rejected(self):
db = _session()
db.add(_row(id="row-other", parent_id="parent-2"))
db.commit()
with self.assertRaises(ValueError):
_sync(db, [{"id": "row-other", "item_code": "R-1", "item_name": "占用"}])
other = db.query(DemoChild).filter_by(id="row-other").one()
self.assertEqual(other.parent_id, "parent-2")
self.assertEqual(other.del_flag, 0)
def test_readded_business_key_restores_deleted_row(self):
db = _session()
db.add(_row(id="row-old", item_code="R-1", del_flag=1))
db.commit()
now = datetime(2026, 10, 27, 10, 0, 0)
def build_row(row_id):
return DemoChild(
id=row_id,
parent_id="parent-1",
tenant_no="tenant-a",
create_time=now,
)
def apply_row(row, item, idx, _now):
row.item_code = item.get("item_code")
row.item_name = item.get("item_name")
row.order_num = idx
saved = sync_child_rows(
db,
DemoChild,
parent_attr="parent_id",
parent_id="parent-1",
tenant_no="tenant-a",
items=[{"item_code": "R-1", "item_name": "恢复"}],
user_id="user-1",
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda item: (item.get("item_code") or "").strip(),
row_natural_key=lambda row: (row.item_code or "").strip(),
reuse_deleted=True,
)
db.commit()
self.assertEqual(saved[0].id, "row-old")
self.assertEqual(saved[0].del_flag, 0)
self.assertEqual(db.query(DemoChild).count(), 1)
def test_removing_live_row_clears_older_tombstone(self):
db = _session()
db.add(_row(id="row-live", item_code="R-1"))
db.add(_row(id="row-tomb", item_code="R-1", del_flag=1))
db.commit()
now = datetime(2026, 10, 27, 10, 0, 0)
sync_child_rows(
db,
DemoChild,
parent_attr="parent_id",
parent_id="parent-1",
tenant_no="tenant-a",
items=[],
user_id="user-1",
now=now,
build_row=lambda row_id: DemoChild(
id=row_id, parent_id="parent-1", tenant_no="tenant-a", create_time=now
),
apply_fields=lambda row, item, idx, _now: None,
natural_key=lambda item: (item.get("item_code") or "").strip(),
row_natural_key=lambda row: (row.item_code or "").strip(),
reuse_deleted=True,
)
db.commit()
self.assertIsNone(db.query(DemoChild).filter_by(id="row-tomb").first())
live = db.query(DemoChild).filter_by(id="row-live").one()
self.assertEqual(live.del_flag, 1)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,68 @@
"""离职(软删除)后,历史单据仍应展示人员姓名,而不是人员 id。"""
from __future__ import annotations
import unittest
from datetime import datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.crud.production_task import _enrich_assignee_names
from app.crud.production_task_step_assignee import enrich_step_completed_by_names
from app.crud.qc_inspection_batch import _assignee_names
from app.models.base import Base
from app.models.models import SysUser
def _user(user_id: str, nick_name: str, *, deleted: bool = False) -> SysUser:
now = datetime(2026, 7, 1, 10, 0, 0)
return SysUser(
id=user_id,
user_no="u-" + user_id[:6],
nick_name=nick_name,
email="",
phone_no="",
password="",
status="0" if deleted else "1",
tenant_no="zkxw",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=1 if deleted else 0,
)
class TestDepartedUserDisplayName(unittest.TestCase):
def setUp(self):
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(self.engine, tables=[SysUser.__table__])
Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
self.db = Session()
self.db.add_all(
[
_user("GONEID0001", "张倩文", deleted=True),
_user("STAYID0001", "侯以婷"),
]
)
self.db.commit()
def tearDown(self):
self.db.close()
def test_qc_batch_assignee_names_keep_departed_nick(self):
text = _assignee_names(self.db, ["GONEID0001", "STAYID0001"])
self.assertEqual(text, "张倩文、侯以婷")
def test_qc_batch_assignee_unknown_id_stays_id(self):
self.assertEqual(_assignee_names(self.db, ["NO_SUCH_USER"]), "NO_SUCH_USER")
def test_production_current_assignee_keeps_departed_nick(self):
items = [{"currentAssigneeUserId": "GONEID0001"}]
_enrich_assignee_names(self.db, items)
self.assertEqual(items[0]["currentAssigneeUserName"], "张倩文")
def test_step_completed_by_keeps_departed_nick(self):
steps = [{"completedBy": "GONEID0001"}]
enrich_step_completed_by_names(self.db, steps)
self.assertIn("张倩文", steps[0]["completedByName"])
self.assertNotIn("GONEID0001", steps[0]["completedByName"])
+105 -3
View File
@@ -578,6 +578,64 @@ def test_specialist_cell_create_forces_current_collection_and_collector_users():
assert sample.sample_code == 'S-001'
def test_mobile_protocol_status_follows_web_options_and_transitions():
from app.services.market_protocol import create_protocol, update_protocol
db = _session()
_masters(db)
created = create_protocol(
db,
body=_body(protocolStatus='dsx'),
ctx=_ctx('specialist'),
user_info=_user(),
)
protocol_id = created['basic']['id']
assert db.get(BizCollectionProtocol, protocol_id).protocol_status == 'dsx'
updated = update_protocol(
db,
source='cell_storage',
protocol_id=protocol_id,
body=_body(protocolStatus='yzf'),
ctx=_ctx('specialist'),
user_info=_user(),
)
assert updated['basic']['protocolStatus'] == 'yzf'
with pytest.raises(ValueError, match='不可转为'):
update_protocol(
db,
source='cell_storage',
protocol_id=protocol_id,
body=_body(protocolStatus='sxz'),
ctx=_ctx('specialist'),
user_info=_user(),
)
donation = create_protocol(
db,
body=_body(
'research_donation',
donationNo='D-STATUS',
protocolNo=None,
donationStatus='sdfq',
attachments={},
samples=[
{
'sampleTypeId': 'type-a',
'sampleCodeId': 'code-b',
'sampleCode': 'S-002',
'unitId': 'unit-a',
'collectAmount': 10,
}
],
),
ctx=_ctx('specialist'),
user_info=_user(),
)
assert db.get(BizResearchDonation, donation['basic']['id']).donation_status == 'sdfq'
def test_specialist_donation_create_forces_current_users_and_has_no_payment_section():
from app.services.market_protocol import create_protocol
@@ -1068,6 +1126,44 @@ 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
@@ -3052,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': '通过'},
@@ -3061,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():
@@ -3730,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()
@@ -3785,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()
+161
View File
@@ -0,0 +1,161 @@
"""保存步骤产出物时保留原行 ID,避免质检工单等外键被整表替换打断。"""
import unittest
from datetime import datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.crud.production_task import replace_step_outputs
from app.models.models import BizProductionTaskStepOutput
def _session():
engine = create_engine("sqlite://")
BizProductionTaskStepOutput.__table__.create(bind=engine)
return sessionmaker(bind=engine)()
def _output(**kwargs):
now = datetime(2026, 9, 2, 9, 1, 8)
data = dict(
step_id="step-1",
tenant_no="tenant-a",
output_type_id="type-valid",
output_name="细胞悬液",
output_barcode="NK-UBMC202603009",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
data.update(kwargs)
return BizProductionTaskStepOutput(**data)
class TestReplaceStepOutputsKeepsIdentity(unittest.TestCase):
def test_existing_row_keeps_id_when_fields_change(self):
db = _session()
db.add(_output(id="out-keep"))
db.commit()
saved = replace_step_outputs(
db,
"step-1",
"tenant-a",
"user-1",
[
{
"id": "out-keep",
"output_type_id": "type-valid",
"output_name": "细胞悬液",
"output_barcode": "NK-UBMC202603009-NEW",
"remark": "",
}
],
)
self.assertEqual(saved[0]["id"], "out-keep")
self.assertEqual(saved[0]["outputBarcode"], "NK-UBMC202603009-NEW")
alive = (
db.query(BizProductionTaskStepOutput)
.filter(BizProductionTaskStepOutput.del_flag == 0)
.all()
)
self.assertEqual([row.id for row in alive], ["out-keep"])
def test_insert_new_update_old_and_soft_delete_only_removed(self):
db = _session()
db.add(_output(id="out-keep", output_name="细胞悬液", output_barcode="NK-OLD"))
db.add(_output(id="out-drop", output_name="细胞上清", output_barcode="NK-DROP"))
db.commit()
saved = replace_step_outputs(
db,
"step-1",
"tenant-a",
"user-1",
[
{
"id": "out-keep",
"output_type_id": "type-valid",
"output_name": "细胞悬液",
"output_barcode": "NK-UPDATED",
"remark": "改条码",
},
{
"output_type_id": "type-valid",
"output_name": "废液",
"output_barcode": "NK-NEW",
"remark": "",
},
],
)
self.assertEqual(saved[0]["id"], "out-keep")
self.assertEqual(saved[0]["outputBarcode"], "NK-UPDATED")
self.assertTrue(saved[1]["id"])
self.assertNotEqual(saved[1]["id"], "out-keep")
self.assertEqual(saved[1]["outputName"], "废液")
rows = {row.id: row for row in db.query(BizProductionTaskStepOutput).all()}
self.assertEqual(rows["out-keep"].del_flag, 0)
self.assertEqual(rows["out-keep"].output_barcode, "NK-UPDATED")
self.assertEqual(rows["out-drop"].del_flag, 1)
self.assertEqual(rows[saved[1]["id"]].del_flag, 0)
self.assertEqual(rows[saved[1]["id"]].output_barcode, "NK-NEW")
def test_removed_row_is_soft_deleted_and_kept_row_stays(self):
db = _session()
db.add(_output(id="out-keep", output_name="细胞悬液"))
db.add(_output(id="out-drop", output_name="细胞上清"))
db.commit()
replace_step_outputs(
db,
"step-1",
"tenant-a",
"user-1",
[
{
"id": "out-keep",
"output_type_id": "type-valid",
"output_name": "细胞悬液",
"output_barcode": "NK-UBMC202603009",
"remark": "",
}
],
)
rows = {row.id: row for row in db.query(BizProductionTaskStepOutput).all()}
self.assertEqual(rows["out-keep"].del_flag, 0)
self.assertEqual(rows["out-drop"].del_flag, 1)
def test_id_from_another_step_is_rejected(self):
db = _session()
db.add(_output(id="out-other", step_id="step-2"))
db.commit()
with self.assertRaises(ValueError):
replace_step_outputs(
db,
"step-1",
"tenant-a",
"user-1",
[
{
"id": "out-other",
"output_type_id": "type-valid",
"output_name": "细胞悬液",
"output_barcode": "NK-UBMC202603009",
"remark": "",
}
],
)
other = db.query(BizProductionTaskStepOutput).filter_by(id="out-other").one()
self.assertEqual(other.step_id, "step-2")
self.assertEqual(other.del_flag, 0)
if __name__ == "__main__":
unittest.main()
@@ -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()
+251 -1
View File
@@ -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()
+134
View File
@@ -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()
@@ -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'
)
);
@@ -1,21 +0,0 @@
-- =============================================================================
-- 菜单改名:客户管理 → 客户基础信息
-- id: 121
-- perms: biz:customer:list
-- 父级:市场基础信息
--
-- 【前端固定菜单】同步修改:
-- - web-admin/src/router/modules/sqxp.ts
-- - backend/app/api/menu.py
-- =============================================================================
UPDATE sys_menu
SET menu_name = '客户基础信息',
update_time = NOW()
WHERE del_flag = 0
AND (
id = '121'
OR perms = 'biz:customer:list'
)
AND menu_type = 'C'
AND menu_name <> '客户基础信息';
@@ -1,21 +0,0 @@
-- =============================================================================
-- 菜单改名:样本信息管理 → 客户服务管理
-- id: 50000000000000000000000000000013
-- perms: cell-storage:sample-info:list
-- 父级:存储服务
--
-- 【前端固定菜单】后续任务同步修改:
-- - web-admin/src/router/modules/sqxp.ts
-- - backend/app/api/menu.py
-- =============================================================================
UPDATE sys_menu
SET menu_name = '客户服务管理',
update_time = NOW()
WHERE del_flag = 0
AND (
id = '50000000000000000000000000000013'
OR perms = 'cell-storage:sample-info:list'
)
AND menu_type = 'C'
AND menu_name <> '客户服务管理';
@@ -1,11 +0,0 @@
-- 科研捐赠:当月核量号(创建时按 create_time 年月生成 YYYYMM###;历史可空)
-- Spec: docs/superpowers/specs/2026-10-13-research-donation-list-excel-parity-design.md
SET NAMES utf8mb4;
ALTER TABLE `biz_research_donation`
ADD COLUMN `monthly_verify_no` varchar(16) DEFAULT NULL COMMENT '当月核量号 YYYYMM+3位序号' AFTER `donation_no`;
-- 租户内号唯一;多行 NULL 在 MySQL 下不冲突
ALTER TABLE `biz_research_donation`
ADD UNIQUE KEY `uk_rd_tenant_monthly_verify_no` (`tenant_no`, `monthly_verify_no`);
@@ -1,158 +0,0 @@
-- 样本级储存证 / 保险 + 协议级数据迁移(2026-10-13)
-- 对齐 docs/superpowers/specs/2026-10-13-customer-service-management-design.md
CREATE TABLE IF NOT EXISTS biz_sample_storage_cert (
id VARCHAR(64) NOT NULL COMMENT '主键ID',
sample_code VARCHAR(32) NOT NULL COMMENT '样本编码',
collection_protocol_id VARCHAR(64) DEFAULT NULL COMMENT '采集协议单ID冗余',
make_status CHAR(1) DEFAULT '0' COMMENT '制作状态 0-未制作 1-已制作',
cert_image_urls VARCHAR(4000) DEFAULT NULL COMMENT '证书图片URLs',
make_date CHAR(8) DEFAULT NULL COMMENT '制作日期YYYYMMDD',
handle_time DATETIME DEFAULT NULL COMMENT '办理时间',
handler_user_id VARCHAR(64) DEFAULT NULL COMMENT '办理人用户ID',
mail_cert_time DATETIME 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_bssc_tenant_sample (tenant_no, sample_code),
KEY idx_bssc_protocol (tenant_no, collection_protocol_id, del_flag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='样本储存证办理';
-- 注:uk_bssc_tenant_sample 不含 del_flag;del_flag=0 行唯一性由应用层保证(与 biz_sample_storage_cert_qc 一致)。
CREATE TABLE IF NOT EXISTS biz_sample_insurance (
id VARCHAR(64) NOT NULL COMMENT '主键ID',
sample_code VARCHAR(32) NOT NULL COMMENT '样本编码',
collection_protocol_id VARCHAR(64) DEFAULT NULL COMMENT '采集协议单ID冗余',
todo_status CHAR(1) NOT NULL DEFAULT '0' COMMENT '1已办保险 0未办保险',
handle_date CHAR(8) DEFAULT NULL COMMENT '办理日期',
handle_time DATETIME DEFAULT NULL COMMENT '办理时间',
handler_user_id VARCHAR(64) DEFAULT NULL COMMENT '办理人用户ID',
handle_remark VARCHAR(512) DEFAULT NULL COMMENT '办理备注',
insurance_attachments VARCHAR(4000) DEFAULT NULL COMMENT '保险附件逗号分隔',
insurance_company_id VARCHAR(64) DEFAULT NULL COMMENT '保险公司ID',
insurance_company_name VARCHAR(200) DEFAULT NULL COMMENT '保险公司名称(冗余快照)',
insurance_type VARCHAR(200) DEFAULT NULL COMMENT '保险险种',
insurance_policy_no VARCHAR(100) DEFAULT NULL COMMENT '保险单号',
insurance_effective_date CHAR(8) DEFAULT NULL COMMENT '保险单生效时间YYYYMMDD',
insurance_expiry_date CHAR(8) DEFAULT NULL COMMENT '保险单失效时间YYYYMMDD',
insurance_subscribed CHAR(1) DEFAULT '0' COMMENT '是否已订购保险',
insurance_fee DECIMAL(18, 2) DEFAULT NULL COMMENT '保单费用(元)',
policy_send_time DATETIME 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_bsi_tenant_sample (tenant_no, sample_code),
KEY idx_bsi_protocol (tenant_no, collection_protocol_id, del_flag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='样本保险办理';
-- 注:uk_bsi_tenant_sample 不含 del_flag;del_flag=0 行唯一性由应用层保证(与 biz_sample_storage_cert_qc 一致)。
-- 迁移:协议级储存证 → 协议下每个未删样本一行(幂等 NOT EXISTS)
INSERT INTO biz_sample_storage_cert (
id, sample_code, collection_protocol_id, make_status, cert_image_urls,
make_date, handle_time, handler_user_id, mail_cert_time,
tenant_no, order_num, remark, create_by, create_dept_id, create_time,
update_by, update_time, del_flag
)
SELECT
-- MySQL 5.7 同语句批量 UUID() 可能撞主键,用业务键派生稳定唯一 id
MD5(CONCAT('bssc#', c.tenant_no, '#', ps.sample_code, '#', c.id, '#', ps.id)),
ps.sample_code,
c.collection_protocol_id,
c.make_status,
c.cert_image_urls,
c.make_date,
c.handle_time,
c.handler_user_id,
NULL,
c.tenant_no,
c.order_num,
c.remark,
c.create_by,
c.create_dept_id,
NOW(),
c.update_by,
NOW(),
0
FROM biz_protocol_storage_cert c
INNER JOIN biz_protocol_sample ps
ON (ps.collection_protocol_id COLLATE utf8mb4_unicode_ci) = (c.collection_protocol_id COLLATE utf8mb4_unicode_ci)
AND (ps.tenant_no COLLATE utf8mb4_unicode_ci) = (c.tenant_no COLLATE utf8mb4_unicode_ci)
AND ps.del_flag = 0
WHERE c.del_flag = 0
AND ps.sample_code IS NOT NULL
AND ps.sample_code <> ''
AND NOT EXISTS (
SELECT 1 FROM biz_sample_storage_cert t
WHERE (t.tenant_no COLLATE utf8mb4_unicode_ci) = (c.tenant_no COLLATE utf8mb4_unicode_ci)
AND (t.sample_code COLLATE utf8mb4_unicode_ci) = (ps.sample_code COLLATE utf8mb4_unicode_ci)
AND t.del_flag = 0
);
-- 迁移:协议级满月保险 → 协议下每个未删样本一行(幂等 NOT EXISTS)
INSERT INTO biz_sample_insurance (
id, sample_code, collection_protocol_id, todo_status, handle_date, handle_time,
handler_user_id, handle_remark, insurance_attachments,
insurance_company_id, insurance_company_name, insurance_type, insurance_policy_no,
insurance_effective_date, insurance_expiry_date, insurance_subscribed, insurance_fee,
policy_send_time,
tenant_no, order_num, remark, create_by, create_dept_id, create_time,
update_by, update_time, del_flag
)
SELECT
MD5(CONCAT('bsi#', t.tenant_no, '#', ps.sample_code, '#', t.id, '#', ps.id)),
ps.sample_code,
t.collection_protocol_id,
t.todo_status,
t.handle_date,
t.handle_time,
t.handler_user_id,
t.handle_remark,
t.insurance_attachments,
t.insurance_company_id,
t.insurance_company_name,
t.insurance_type,
t.insurance_policy_no,
t.insurance_effective_date,
t.insurance_expiry_date,
t.insurance_subscribed,
t.insurance_fee,
NULL,
t.tenant_no,
t.order_num,
t.remark,
t.create_by,
t.create_dept_id,
NOW(),
t.update_by,
NOW(),
0
FROM biz_protocol_fullmoon_todo t
INNER JOIN biz_protocol_sample ps
ON (ps.collection_protocol_id COLLATE utf8mb4_unicode_ci) = (t.collection_protocol_id COLLATE utf8mb4_unicode_ci)
AND (ps.tenant_no COLLATE utf8mb4_unicode_ci) = (t.tenant_no COLLATE utf8mb4_unicode_ci)
AND ps.del_flag = 0
WHERE t.del_flag = 0
AND ps.sample_code IS NOT NULL
AND ps.sample_code <> ''
AND NOT EXISTS (
SELECT 1 FROM biz_sample_insurance s
WHERE (s.tenant_no COLLATE utf8mb4_unicode_ci) = (t.tenant_no COLLATE utf8mb4_unicode_ci)
AND (s.sample_code COLLATE utf8mb4_unicode_ci) = (ps.sample_code COLLATE utf8mb4_unicode_ci)
AND s.del_flag = 0
);
@@ -1,12 +0,0 @@
-- 样本运费表校对规则对齐存量库 utf8mb4_unicode_ci
-- 背景:2026-10-07 建表仅写 DEFAULT CHARSET=utf8mb4,MySQL 5.7 默认落成
-- utf8mb4_general_ci;与协议/样本等 unicode_ci 表 JOIN 会报 1267
-- Illegal mix of collations,移动端 /mini/market/*/freights 返回 503。
SET NAMES utf8mb4;
ALTER TABLE `biz_sample_freight`
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `biz_sample_freight_item`
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -1,119 +0,0 @@
-- 采集协议单缴费 v1→v2 迁移 · 清单与校验(只读,不改数据)
-- 实际迁移请用:backend/scripts/migrate_protocol_payment_v1_to_v2.py
-- MySQL 5.7 / utf8mb4
SET NAMES utf8mb4;
-- ---------------------------------------------------------------------------
-- 1) 仍为 v1 的协议概览
-- ---------------------------------------------------------------------------
SELECT
p.id AS protocol_id,
p.protocol_no,
p.tenant_no,
p.payment_model_version,
(
SELECT COUNT(*)
FROM biz_protocol_sample s
WHERE s.collection_protocol_id = p.id
AND s.del_flag = 0
AND IFNULL(s.sample_status, '') <> 'yzf'
) AS active_sample_count,
(
SELECT COUNT(*)
FROM biz_protocol_payment pay
WHERE pay.collection_protocol_id = p.id AND pay.del_flag = 0
) AS v1_payment_count,
(
SELECT COUNT(*)
FROM biz_protocol_sample_payment sp
WHERE sp.collection_protocol_id = p.id AND sp.del_flag = 0
) AS v2_sample_payment_count
FROM biz_collection_protocol p
WHERE p.del_flag = 0
AND p.payment_model_version = 1
ORDER BY p.create_time;
-- ---------------------------------------------------------------------------
-- 2) 人工清单候选:多样本
-- ---------------------------------------------------------------------------
SELECT
p.id AS protocol_id,
p.protocol_no,
COUNT(s.id) AS active_sample_count,
'multi_sample' AS skip_reason
FROM biz_collection_protocol p
INNER JOIN biz_protocol_sample s
ON s.collection_protocol_id = p.id
AND s.del_flag = 0
AND IFNULL(s.sample_status, '') <> 'yzf'
WHERE p.del_flag = 0
AND p.payment_model_version = 1
GROUP BY p.id, p.protocol_no
HAVING COUNT(s.id) > 1;
-- ---------------------------------------------------------------------------
-- 3) 人工清单候选:有退费痕迹的明细
-- ---------------------------------------------------------------------------
SELECT DISTINCT
p.id AS protocol_id,
p.protocol_no,
d.id AS detail_id,
d.pay_status,
d.refund_amount,
'has_refund' AS skip_reason
FROM biz_collection_protocol p
INNER JOIN biz_protocol_payment pay
ON pay.collection_protocol_id = p.id AND pay.del_flag = 0
INNER JOIN biz_protocol_payment_detail d
ON d.payment_id = pay.id AND d.del_flag = 0
WHERE p.del_flag = 0
AND p.payment_model_version = 1
AND (
d.pay_status IN ('tfqrz', 'ytf')
OR (d.refund_amount IS NOT NULL AND d.refund_amount > 0)
OR IFNULL(d.refund_attachments, '') <> ''
OR IFNULL(d.refund_remark, '') <> ''
OR IFNULL(d.refund_finance_confirm_attachments, '') <> ''
OR IFNULL(d.refund_finance_remark, '') <> ''
);
-- ---------------------------------------------------------------------------
-- 4) 可自动迁(单样本、有 v1 缴费、尚无 v2 方案)粗筛
-- 注意:退费还需用第 3 节排除,本查询仅看样本数
-- ---------------------------------------------------------------------------
SELECT
p.id AS protocol_id,
p.protocol_no,
s.sample_code,
pay.contract_amount,
pay.pay_mode
FROM biz_collection_protocol p
INNER JOIN biz_protocol_payment pay
ON pay.collection_protocol_id = p.id AND pay.del_flag = 0
INNER JOIN biz_protocol_sample s
ON s.collection_protocol_id = p.id
AND s.del_flag = 0
AND IFNULL(s.sample_status, '') <> 'yzf'
WHERE p.del_flag = 0
AND p.payment_model_version = 1
AND NOT EXISTS (
SELECT 1 FROM biz_protocol_sample_payment sp
WHERE sp.collection_protocol_id = p.id AND sp.del_flag = 0
)
GROUP BY p.id, p.protocol_no, s.sample_code, pay.contract_amount, pay.pay_mode
HAVING COUNT(DISTINCT s.id) = 1;
-- ---------------------------------------------------------------------------
-- 5) 迁移后抽样校验:某协议 v1 detail 金额合计 vs v2 transfer 金额合计
-- 将 @pid 换成协议 ID
-- ---------------------------------------------------------------------------
-- SET @pid := 'YOUR_PROTOCOL_ID';
-- SELECT 'v1_detail' AS src, COUNT(*) AS cnt, IFNULL(SUM(transfer_amount), 0) AS amt
-- FROM biz_protocol_payment_detail d
-- INNER JOIN biz_protocol_payment pay ON pay.id = d.payment_id AND pay.del_flag = 0
-- WHERE pay.collection_protocol_id = @pid AND d.del_flag = 0
-- UNION ALL
-- SELECT 'v2_transfer', COUNT(*), IFNULL(SUM(transfer_amount), 0)
-- FROM biz_protocol_payment_transfer t
-- WHERE t.collection_protocol_id = @pid AND t.del_flag = 0;
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff. Show more