Files
zkxw/backend/app/crud/child_row_sync.py
T
zlwuandCursor 5e563a6669 fix: 子表保存改为就地同步,避免整批换 id 打断外键。
协议样本、生产产出和盘点明细按行更新并保留原 id;补齐存储证明质检预填、离职人员显示名,以及样本采集通知模板字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 10:51:53 +08:00

258 lines
8.5 KiB
Python

"""按行同步子表。
带 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,
)