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

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

823 lines
25 KiB
Python

"""生产任务集合 CRUD"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional, Set, Tuple
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.models.models import (
BizProcessRoute,
BizProcessRouteStep,
BizProductionTask,
BizProductionTaskSet,
BizProductionTaskSetItem,
BizProductionWorkOrder,
)
from app.utils.production_task_name_placeholders import (
apply_task_name_placeholders,
revert_task_name_placeholders,
)
def resolve_work_order_lab_storage_code(
db: Session,
*,
tenant_no: str,
wo: BizProductionWorkOrder,
) -> str:
"""解析工单对应实验室入库编码(样本接收优先,样本编码回退)。"""
from app.crud import sample_reception as sample_reception_svc
from app.models.models import BizSampleReception
reception_id = (getattr(wo, "reception_id", None) or "").strip()
if reception_id:
rec = (
db.query(BizSampleReception)
.filter(
BizSampleReception.id == reception_id,
BizSampleReception.tenant_no == tenant_no,
BizSampleReception.del_flag == 0,
)
.first()
)
if rec:
code = (rec.lab_storage_code or "").strip()
if code:
return code
sample_code = (getattr(wo, "sample_code", None) or "").strip()
if not sample_code:
return ""
source = (getattr(wo, "reception_source", None) or "").strip() or None
code_map = sample_reception_svc.get_lab_storage_code_map_by_sample_codes(
db, tenant_no, [sample_code], reception_source=source
)
return (code_map.get(sample_code) or "").strip()
def _strip_or_none(raw: Any) -> Optional[str]:
text = str(raw or "").strip()
return text or None
def normalize_item_payload(raw: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
data = raw or {}
task_name = str(data.get("taskName") or data.get("task_name") or "").strip()
if not task_name:
return None
route_raw = data.get("routeId") if "routeId" in data else data.get("route_id")
return {
"task_name": task_name,
"route_id": _strip_or_none(route_raw),
"description": _strip_or_none(data.get("description")),
"remark": _strip_or_none(data.get("remark")),
}
def skip_empty_named_tasks(tasks: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], int]:
kept: List[Dict[str, Any]] = []
skipped = 0
for row in tasks or []:
name = str(row.get("task_name") or row.get("taskName") or "").strip()
if not name:
skipped += 1
continue
route_raw = row.get("route_id") if "route_id" in row else row.get("routeId")
name_raw = row.get("route_name") if "route_name" in row else row.get("routeName")
kept.append(
{
"task_name": name,
"route_id": _strip_or_none(route_raw),
"route_name": _strip_or_none(name_raw),
"description": _strip_or_none(row.get("description")),
"remark": _strip_or_none(row.get("remark")),
}
)
return kept, skipped
def collect_missing_route_ids(items: List[Dict[str, Any]], existing_ids: Set[str]) -> List[str]:
missing: List[str] = []
seen: Set[str] = set()
alive = existing_ids or set()
for item in items or []:
rid = _strip_or_none(item.get("route_id"))
if not rid or rid in alive or rid in seen:
continue
seen.add(rid)
missing.append(rid)
return missing
def collect_route_ids_without_steps(
items: List[Dict[str, Any]],
route_ids_with_steps: Set[str],
) -> List[str]:
"""返回条目中已绑定路线但无有效工序步骤的 route_id(去重保序)。"""
empty: List[str] = []
seen: Set[str] = set()
with_steps = route_ids_with_steps or set()
for item in items or []:
rid = _strip_or_none(item.get("route_id"))
if not rid or rid in with_steps or rid in seen:
continue
seen.add(rid)
empty.append(rid)
return empty
def _load_route_ids_with_steps(db: Session, route_ids: List[str]) -> Set[str]:
ids = [rid for rid in route_ids if rid]
if not ids:
return set()
rows = (
db.query(BizProcessRouteStep.route_id)
.filter(
BizProcessRouteStep.route_id.in_(ids),
BizProcessRouteStep.del_flag == 0,
)
.distinct()
.all()
)
return {r[0] for r in rows if r and r[0]}
def total_pages(total: int, page_size: int) -> int:
if page_size <= 0:
return 0
return (int(total) + int(page_size) - 1) // int(page_size)
def _item_to_dict(r: BizProductionTaskSetItem) -> Dict[str, Any]:
return {
"id": r.id,
"setId": r.set_id,
"taskName": r.task_name or "",
"routeId": r.route_id or "",
"routeName": r.route_name or "",
"description": r.description or "",
"remark": r.remark or "",
"orderNum": r.order_num if r.order_num is not None else 0,
}
def _set_to_dict(
r: BizProductionTaskSet,
item_count: int = 0,
items: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
data: Dict[str, Any] = {
"id": r.id,
"setName": r.set_name or "",
"status": r.status or "1",
"itemCount": item_count,
"orderNum": r.order_num if r.order_num is not None else 0,
"remark": r.remark or "",
"isBuiltin": getattr(r, "is_builtin", None) or "0",
"createTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
"updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
}
if items is not None:
data["items"] = items
return data
def set_name_exists(
db: Session, name: str, tenant_no: str, exclude_id: Optional[str] = None
) -> bool:
n = (name or "").strip()
if not n:
return False
q = db.query(BizProductionTaskSet).filter(
BizProductionTaskSet.set_name == n,
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.del_flag == 0,
)
if exclude_id:
q = q.filter(BizProductionTaskSet.id != exclude_id)
return q.first() is not None
def _require_status(status: Optional[str]) -> str:
st = (status or "1").strip()
if st not in ("0", "1"):
raise ValueError("状态无效")
return st
def _load_route_name_map(db: Session, tenant_no: str, route_ids: List[str]) -> Dict[str, str]:
ids = [rid for rid in route_ids if rid]
if not ids:
return {}
rows = (
db.query(BizProcessRoute)
.filter(
BizProcessRoute.tenant_no == tenant_no,
BizProcessRoute.del_flag == 0,
BizProcessRoute.id.in_(ids),
)
.all()
)
return {r.id: (r.route_name or "") for r in rows}
def _normalize_items_or_raise(raw_items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for raw in raw_items or []:
item = normalize_item_payload(raw)
if item:
out.append(item)
if not out:
raise ValueError("请至少添加一条任务名称非空的条目")
return out
def _assert_routes_exist(
db: Session, tenant_no: str, items: List[Dict[str, Any]]
) -> Dict[str, str]:
wanted = [i["route_id"] for i in items if i.get("route_id")]
name_map = _load_route_name_map(db, tenant_no, wanted)
missing = collect_missing_route_ids(items, set(name_map.keys()))
if missing:
raise ValueError("存在无效或已删除的工艺路线")
return name_map
def _replace_items(
db: Session,
*,
set_id: str,
tenant_no: str,
items: List[Dict[str, Any]],
route_names: Dict[str, str],
user_id: Optional[str],
create_dept_id: str,
) -> None:
now = datetime.now()
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,
)
def apply_item(row, item, idx, _now):
rid = item.get("route_id")
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]:
if not set_ids:
return {}
rows = (
db.query(BizProductionTaskSetItem.set_id, func.count(BizProductionTaskSetItem.id))
.filter(
BizProductionTaskSetItem.tenant_no == tenant_no,
BizProductionTaskSetItem.del_flag == 0,
BizProductionTaskSetItem.set_id.in_(set_ids),
)
.group_by(BizProductionTaskSetItem.set_id)
.all()
)
return {sid: int(cnt) for sid, cnt in rows}
def list_sets(
db: Session,
*,
tenant_no: str,
page: int = 1,
page_size: int = 10,
keyword: Optional[str] = None,
status: Optional[str] = None,
) -> Tuple[List[Dict[str, Any]], int]:
q = db.query(BizProductionTaskSet).filter(
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.del_flag == 0,
)
kw = (keyword or "").strip()
if kw:
q = q.filter(BizProductionTaskSet.set_name.like(f"%{kw}%"))
st = (status or "").strip()
if st in ("0", "1"):
q = q.filter(BizProductionTaskSet.status == st)
total = q.count()
rows = (
q.order_by(BizProductionTaskSet.create_time.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
counts = _item_count_map(db, tenant_no, [r.id for r in rows])
return [_set_to_dict(r, item_count=counts.get(r.id, 0)) for r in rows], int(total)
def list_active_sets(db: Session, *, tenant_no: str) -> List[Dict[str, Any]]:
rows = (
db.query(BizProductionTaskSet)
.filter(
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.del_flag == 0,
BizProductionTaskSet.status == "1",
)
.order_by(BizProductionTaskSet.create_time.desc())
.all()
)
counts = _item_count_map(db, tenant_no, [r.id for r in rows])
return [_set_to_dict(r, item_count=counts.get(r.id, 0)) for r in rows]
def get_set(db: Session, set_id: str, tenant_no: str) -> Optional[Dict[str, Any]]:
row = (
db.query(BizProductionTaskSet)
.filter(
BizProductionTaskSet.id == set_id,
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.del_flag == 0,
)
.first()
)
if not row:
return None
items = (
db.query(BizProductionTaskSetItem)
.filter(
BizProductionTaskSetItem.set_id == set_id,
BizProductionTaskSetItem.tenant_no == tenant_no,
BizProductionTaskSetItem.del_flag == 0,
)
.order_by(
BizProductionTaskSetItem.order_num.asc(),
BizProductionTaskSetItem.create_time.asc(),
)
.all()
)
return _set_to_dict(row, item_count=len(items), items=[_item_to_dict(i) for i in items])
def is_super_admin_user(user: Optional[Dict[str, Any]]) -> bool:
return bool(user) and (user.get("role") or "") == "super_admin"
def assert_set_writable(
db: Session,
set_id: str,
tenant_no: str,
user: Optional[Dict[str, Any]] = None,
) -> BizProductionTaskSet:
"""内置模版仅超级管理员可写。"""
row = (
db.query(BizProductionTaskSet)
.filter(
BizProductionTaskSet.id == set_id,
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.del_flag == 0,
)
.first()
)
if not row:
raise ValueError("集合不存在")
if (getattr(row, "is_builtin", None) or "0") == "1" and not is_super_admin_user(user):
raise ValueError("内置模版仅超级管理员可修改")
return row
def create_set(
db: Session,
*,
tenant_no: str,
user_id: Optional[str] = None,
create_dept_id: str = "",
set_name: str,
status: str = "1",
remark: Optional[str] = None,
items: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
name = (set_name or "").strip()
if not name:
raise ValueError("集合名称不能为空")
if set_name_exists(db, name, tenant_no):
raise ValueError("集合名称已存在")
normalized = _normalize_items_or_raise(items or [])
route_names = _assert_routes_exist(db, tenant_no, normalized)
now = datetime.now()
obj = BizProductionTaskSet(
id=generate_uuid(),
set_name=name,
status=_require_status(status),
tenant_no=tenant_no,
order_num=0,
remark=(remark or "").strip() or None,
is_builtin="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(obj)
db.flush()
_replace_items(
db,
set_id=obj.id,
tenant_no=tenant_no,
items=normalized,
route_names=route_names,
user_id=user_id,
create_dept_id=create_dept_id,
)
db.flush()
detail = get_set(db, obj.id, tenant_no)
if not detail:
raise ValueError("集合创建失败")
return detail
def update_set(
db: Session,
set_id: str,
*,
tenant_no: str,
user_id: Optional[str] = None,
set_name: Optional[str] = None,
status: Optional[str] = None,
remark: Optional[str] = None,
items: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
obj = (
db.query(BizProductionTaskSet)
.filter(
BizProductionTaskSet.id == set_id,
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.del_flag == 0,
)
.first()
)
if not obj:
raise ValueError("集合不存在")
if set_name is not None:
name = set_name.strip()
if not name:
raise ValueError("集合名称不能为空")
if set_name_exists(db, name, tenant_no, exclude_id=set_id):
raise ValueError("集合名称已存在")
obj.set_name = name
if status is not None:
obj.status = _require_status(status)
if remark is not None:
obj.remark = remark.strip() or None
if items is not None:
normalized = _normalize_items_or_raise(items)
route_names = _assert_routes_exist(db, tenant_no, normalized)
_replace_items(
db,
set_id=obj.id,
tenant_no=tenant_no,
items=normalized,
route_names=route_names,
user_id=user_id,
create_dept_id=obj.create_dept_id or "",
)
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
db.flush()
detail = get_set(db, obj.id, tenant_no)
if not detail:
raise ValueError("集合不存在")
return detail
def delete_set(db: Session, set_id: str, *, tenant_no: str, user_id: Optional[str] = None) -> None:
obj = (
db.query(BizProductionTaskSet)
.filter(
BizProductionTaskSet.id == set_id,
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.del_flag == 0,
)
.first()
)
if not obj:
raise ValueError("集合不存在")
now = datetime.now()
obj.del_flag = 1
obj.update_by = user_id
obj.update_time = now
db.add(obj)
items = (
db.query(BizProductionTaskSetItem)
.filter(
BizProductionTaskSetItem.set_id == set_id,
BizProductionTaskSetItem.del_flag == 0,
)
.all()
)
for row in items:
row.del_flag = 1
row.update_by = user_id
row.update_time = now
db.add(row)
db.flush()
def create_set_from_work_order(
db: Session,
*,
tenant_no: str,
user_id: Optional[str] = None,
create_dept_id: str = "",
work_order_id: str,
set_name: str,
remark: Optional[str] = None,
items: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
wo_id = (work_order_id or "").strip()
wo = (
db.query(BizProductionWorkOrder)
.filter(
BizProductionWorkOrder.id == wo_id,
BizProductionWorkOrder.tenant_no == tenant_no,
BizProductionWorkOrder.del_flag == 0,
)
.first()
)
if not wo:
raise ValueError("工单不存在")
if items is not None:
raw = items
else:
tasks = (
db.query(BizProductionTask)
.filter(
BizProductionTask.work_order_id == wo_id,
BizProductionTask.tenant_no == tenant_no,
BizProductionTask.del_flag == 0,
)
.order_by(BizProductionTask.create_time.asc(), BizProductionTask.id.asc())
.all()
)
raw = [
{
"task_name": t.task_name,
"route_id": t.route_id,
"description": t.description,
"remark": t.remark,
}
for t in tasks
]
kept, skipped = skip_empty_named_tasks(raw)
if not kept:
raise ValueError("无可保存的生产任务(任务名称均为空)")
lab_storage_code = resolve_work_order_lab_storage_code(
db, tenant_no=tenant_no, wo=wo
)
route_ids = [i["route_id"] for i in kept if i.get("route_id")]
name_map = _load_route_name_map(db, tenant_no, route_ids)
payload_items = []
for i in kept:
rid = i.get("route_id")
if rid and rid not in name_map:
rid = None
payload_items.append(
{
"taskName": revert_task_name_placeholders(
i["task_name"] or "",
lab_storage_code=lab_storage_code,
),
"routeId": rid,
"description": i.get("description"),
"remark": i.get("remark"),
}
)
detail = create_set(
db,
tenant_no=tenant_no,
user_id=user_id,
create_dept_id=create_dept_id,
set_name=set_name,
status="1",
remark=remark,
items=payload_items,
)
return {**detail, "skippedEmptyNameCount": skipped}
def create_tasks_from_set(
db: Session,
*,
tenant_no: str,
user_id: Optional[str] = None,
create_dept_id: str = "",
work_order_id: str,
set_id: str,
items: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
from app.crud import production_task as production_task_svc
wo_id = (work_order_id or "").strip()
wo = (
db.query(BizProductionWorkOrder)
.filter(
BizProductionWorkOrder.id == wo_id,
BizProductionWorkOrder.tenant_no == tenant_no,
BizProductionWorkOrder.del_flag == 0,
)
.first()
)
if not wo:
raise ValueError("工单不存在")
status = getattr(wo, "work_order_status", None) or ""
if status not in ("dpz", "jxz"):
raise ValueError("仅待配置或进行中的工单可派发任务")
header = (
db.query(BizProductionTaskSet)
.filter(
BizProductionTaskSet.id == (set_id or "").strip(),
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.del_flag == 0,
)
.first()
)
if not header:
raise ValueError("任务组合模版不存在")
if (header.status or "1") != "1":
raise ValueError("任务组合模版已停用")
if items is not None:
payloads = _normalize_items_or_raise(items)
else:
rows = (
db.query(BizProductionTaskSetItem)
.filter(
BizProductionTaskSetItem.set_id == header.id,
BizProductionTaskSetItem.tenant_no == tenant_no,
BizProductionTaskSetItem.del_flag == 0,
)
.order_by(
BizProductionTaskSetItem.order_num.asc(),
BizProductionTaskSetItem.create_time.asc(),
)
.all()
)
if not rows:
raise ValueError("任务组合模版没有可派发的任务")
payloads = [
{
"route_id": row.route_id,
"task_name": row.task_name,
"description": row.description,
"remark": row.remark,
}
for row in rows
]
route_ids = [p["route_id"] for p in payloads if p.get("route_id")]
name_map = _load_route_name_map(db, tenant_no, route_ids)
missing = collect_missing_route_ids(payloads, set(name_map.keys()))
if missing:
raise ValueError("工艺路线已删除,无法派发:" + "、".join(missing))
with_steps = _load_route_ids_with_steps(db, route_ids)
empty_steps = collect_route_ids_without_steps(payloads, with_steps)
if empty_steps:
labels = [name_map.get(rid) or rid for rid in empty_steps]
raise ValueError("工艺路线无工序步骤,无法派发:" + "、".join(labels))
lab_storage_code = resolve_work_order_lab_storage_code(
db, tenant_no=tenant_no, wo=wo
)
created = []
for row in payloads:
resolved_name = apply_task_name_placeholders(
row["task_name"] or "",
lab_storage_code=lab_storage_code,
)
task = production_task_svc.create_production_task(
db,
tenant_no=tenant_no,
user_id=user_id,
create_dept_id=create_dept_id,
data={
"work_order_id": wo_id,
"task_name": resolved_name,
"route_id": row.get("route_id"),
"description": row.get("description"),
"remark": row.get("remark"),
},
)
created.append(production_task_svc._task_to_dict(task))
return {"createdCount": len(created), "list": created}
def try_auto_dispatch_builtin_tasks_after_reception(
db: Session,
*,
tenant_no: str,
user_id: Optional[str] = None,
create_dept_id: str = "",
work_order: BizProductionWorkOrder,
lab_storage_code: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""样本接收建工单后按入库编码尝试自动派发;失败不抛错。"""
import logging
logger = logging.getLogger(__name__)
wo = work_order
if not wo or not getattr(wo, "id", None):
return None
existing = (
db.query(BizProductionTask.id)
.filter(
BizProductionTask.work_order_id == wo.id,
BizProductionTask.tenant_no == tenant_no,
BizProductionTask.del_flag == 0,
)
.first()
)
if existing:
logger.info(
"skip auto-dispatch: work_order %s already has tasks",
wo.id,
)
return None
code = (lab_storage_code or "").strip()
if not code:
code = resolve_work_order_lab_storage_code(db, tenant_no=tenant_no, wo=wo)
from app.utils.lab_storage_code_task_set import (
match_builtin_task_set_name_by_lab_storage_code,
)
set_name = match_builtin_task_set_name_by_lab_storage_code(code)
if not set_name:
logger.info(
"skip auto-dispatch: lab_storage_code=%r no builtin set rule",
code,
)
return None
header = (
db.query(BizProductionTaskSet)
.filter(
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.set_name == set_name,
BizProductionTaskSet.is_builtin == "1",
BizProductionTaskSet.status == "1",
BizProductionTaskSet.del_flag == 0,
)
.first()
)
if not header:
logger.warning(
"skip auto-dispatch: builtin set %r not found/enabled for tenant %s",
set_name,
tenant_no,
)
return None
try:
return create_tasks_from_set(
db,
tenant_no=tenant_no,
user_id=user_id,
create_dept_id=create_dept_id or "",
work_order_id=wo.id,
set_id=header.id,
)
except Exception:
logger.exception(
"auto-dispatch failed: work_order=%s set=%s lab_storage_code=%r",
wo.id,
set_name,
code,
)
return None