协议样本、生产产出和盘点明细按行更新并保留原 id;补齐存储证明质检预填、离职人员显示名,以及样本采集通知模板字段。 Co-authored-by: Cursor <cursoragent@cursor.com>
258 lines
8.5 KiB
Python
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,
|
|
)
|