Files
zkxw/backend/app/services/sample_code_correction.py
T
zlwuandCursor 5dad1bb0e3 fix: 协议子资源单事务保存,并补齐样本编码/OA/质检等近期能力
锁定缴费校验失败时整单回滚,避免样本半成功落库;前端禁止删除锁定缴费样本,改码走更正编码。同步落地样本更正、采集微信通知、OA提及未读、细胞产品套餐与相关迁移文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 11:02:25 +08:00

677 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""样本编码全链路纠错:预览影响面 + 事务级联更新。"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.models import (
BizCellProduct,
BizProductionTaskInput,
BizProductionWorkOrder,
BizProtocolPaymentSchedule,
BizProtocolPaymentTransfer,
BizProtocolSample,
BizProtocolSamplePayment,
BizProtocolSampleTransport,
BizQcTask,
BizQcWorkOrder,
BizSampleCode,
BizSampleCodeCorrectionLog,
BizSampleCustomerService,
BizSampleFreightItem,
BizSampleInsurance,
BizSampleProductionReception,
BizSampleReception,
BizSampleStorageCert,
BizSampleStorageCertQc,
BizStorageReleaseReview,
)
from app.models.research_donation import (
BizResearchDonationSample,
BizResearchDonationSampleTransport,
)
SOURCE_CELL = "cell_storage"
SOURCE_DONATION = "research_donation"
VALID_SOURCES = {SOURCE_CELL, SOURCE_DONATION}
# (key, label, model, code_attr, has_reception_source, cell_only)
_TABLE_SPECS: List[Tuple[str, str, Any, str, bool, bool]] = [
("biz_protocol_sample_payment", "按样本缴费", BizProtocolSamplePayment, "sample_code", False, True),
("biz_protocol_payment_schedule", "缴费期次", BizProtocolPaymentSchedule, "sample_code", False, True),
("biz_protocol_payment_transfer", "缴费转账", BizProtocolPaymentTransfer, "sample_code", False, True),
("biz_protocol_sample_transport", "协议样本运输", BizProtocolSampleTransport, "sample_code", False, True),
(
"biz_research_donation_sample_transport",
"捐赠样本运输",
BizResearchDonationSampleTransport,
"sample_code",
False,
False,
),
("biz_sample_freight_item", "运费明细", BizSampleFreightItem, "sample_code", False, False),
("biz_sample_reception", "样本接收", BizSampleReception, "sample_code", True, False),
("biz_qc_work_order", "质检工单", BizQcWorkOrder, "sample_code", True, False),
("biz_qc_task", "质检任务", BizQcTask, "sample_code", False, False),
("biz_storage_release_review", "入库放行", BizStorageReleaseReview, "sample_code", True, False),
("biz_sample_storage_cert_qc", "储存证质检", BizSampleStorageCertQc, "sample_code", False, True),
(
"biz_sample_production_reception",
"生产部门接收",
BizSampleProductionReception,
"sample_code",
True,
False,
),
("biz_production_work_order", "生产工单", BizProductionWorkOrder, "sample_code", True, False),
("biz_sample_storage_cert", "储存证", BizSampleStorageCert, "sample_code", False, True),
("biz_sample_insurance", "样本保险", BizSampleInsurance, "sample_code", False, True),
("biz_sample_customer_service", "客户服务扩展", BizSampleCustomerService, "sample_code", False, True),
]
_UNIQUE_TABLES = {
"biz_sample_storage_cert",
"biz_sample_insurance",
"biz_sample_customer_service",
"biz_sample_storage_cert_qc",
}
class SampleCodeCorrectionError(ValueError):
"""业务校验失败(可直接返回给前端)。"""
def _norm(code: Optional[str]) -> str:
return (code or "").strip()
def _now() -> datetime:
return datetime.now()
def _load_source_sample(
db: Session,
*,
tenant_no: str,
reception_source: str,
sample_code: str,
):
code = _norm(sample_code)
if reception_source == SOURCE_CELL:
return (
db.query(BizProtocolSample)
.filter(
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.del_flag == 0,
BizProtocolSample.sample_code == code,
)
.first()
)
return (
db.query(BizResearchDonationSample)
.filter(
BizResearchDonationSample.tenant_no == tenant_no,
BizResearchDonationSample.del_flag == 0,
BizResearchDonationSample.sample_code == code,
)
.first()
)
def _code_used_by_other_sample(
db: Session,
*,
tenant_no: str,
sample_code: str,
exclude_sample_id: Optional[str],
) -> bool:
code = _norm(sample_code)
q1 = db.query(BizProtocolSample.id).filter(
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.del_flag == 0,
BizProtocolSample.sample_code == code,
)
q2 = db.query(BizResearchDonationSample.id).filter(
BizResearchDonationSample.tenant_no == tenant_no,
BizResearchDonationSample.del_flag == 0,
BizResearchDonationSample.sample_code == code,
)
if exclude_sample_id:
q1 = q1.filter(BizProtocolSample.id != exclude_sample_id)
q2 = q2.filter(BizResearchDonationSample.id != exclude_sample_id)
return q1.first() is not None or q2.first() is not None
def _count_by_code(
db: Session,
model,
*,
tenant_no: str,
code: str,
code_attr: str = "sample_code",
reception_source: Optional[str] = None,
has_reception_source: bool = False,
) -> int:
col = getattr(model, code_attr)
q = db.query(model).filter(
model.tenant_no == tenant_no,
model.del_flag == 0,
col == code,
)
if has_reception_source and reception_source:
q = q.filter(model.reception_source == reception_source)
return int(q.count())
def _update_by_code(
db: Session,
model,
*,
tenant_no: str,
old_code: str,
new_code: str,
code_attr: str = "sample_code",
reception_source: Optional[str] = None,
has_reception_source: bool = False,
user_id: Optional[str] = None,
) -> int:
col = getattr(model, code_attr)
q = db.query(model).filter(
model.tenant_no == tenant_no,
model.del_flag == 0,
col == old_code,
)
if has_reception_source and reception_source:
q = q.filter(model.reception_source == reception_source)
rows = q.all()
now = _now()
for row in rows:
setattr(row, code_attr, new_code)
if hasattr(row, "update_by"):
row.update_by = user_id
if hasattr(row, "update_time"):
row.update_time = now
return len(rows)
def _count_task_inputs(db: Session, tenant_no: str, code: str) -> int:
return int(
db.query(BizProductionTaskInput)
.filter(
BizProductionTaskInput.tenant_no == tenant_no,
BizProductionTaskInput.del_flag == 0,
BizProductionTaskInput.input_type == "sample",
BizProductionTaskInput.input_code == code,
)
.count()
)
def _update_task_inputs(
db: Session,
*,
tenant_no: str,
old_code: str,
new_code: str,
user_id: Optional[str],
) -> int:
rows = (
db.query(BizProductionTaskInput)
.filter(
BizProductionTaskInput.tenant_no == tenant_no,
BizProductionTaskInput.del_flag == 0,
BizProductionTaskInput.input_type == "sample",
BizProductionTaskInput.input_code == old_code,
)
.all()
)
now = _now()
for row in rows:
row.input_code = new_code
row.update_by = user_id
row.update_time = now
return len(rows)
def _count_same_product_code(db: Session, tenant_no: str, code: str) -> int:
return int(
db.query(BizCellProduct)
.filter(
BizCellProduct.tenant_no == tenant_no,
BizCellProduct.del_flag == 0,
BizCellProduct.product_code == code,
)
.count()
)
def _unique_conflict(
db: Session,
model,
*,
tenant_no: str,
new_code: str,
) -> bool:
return (
db.query(model.id)
.filter(
model.tenant_no == tenant_no,
model.del_flag == 0,
model.sample_code == new_code,
)
.first()
is not None
)
def _spec_applies(reception_source: str, cell_only: bool, table_key: str) -> bool:
if reception_source == SOURCE_CELL:
if table_key == "biz_research_donation_sample_transport":
return False
return True
# donation
if cell_only:
return False
if table_key.startswith("biz_protocol_"):
return False
return True
def preview_correction(
db: Session,
*,
tenant_no: str,
reception_source: str,
old_sample_code: str,
new_sample_code: str,
new_sample_code_id: Optional[str] = None,
) -> Dict[str, Any]:
source = _norm(reception_source) or SOURCE_CELL
old_code = _norm(old_sample_code)
new_code = _norm(new_sample_code)
blockers: List[str] = []
if source not in VALID_SOURCES:
blockers.append("receptionSource 必须是 cell_storage 或 research_donation")
if not old_code:
blockers.append("旧样本编码不能为空")
if not new_code:
blockers.append("新样本编码不能为空")
if old_code and new_code and old_code == new_code:
blockers.append("新样本编码不能与旧编码相同")
sample = None
if not blockers:
sample = _load_source_sample(
db, tenant_no=tenant_no, reception_source=source, sample_code=old_code
)
if sample is None:
blockers.append(f"未找到来源「{source}」下样本编码「{old_code}」")
elif _code_used_by_other_sample(
db,
tenant_no=tenant_no,
sample_code=new_code,
exclude_sample_id=sample.id,
):
blockers.append(f"样本编码「{new_code}」已被其它协议/捐赠样本占用")
impacts: List[Dict[str, Any]] = []
if sample is not None and not blockers:
sot_key = (
"biz_protocol_sample"
if source == SOURCE_CELL
else "biz_research_donation_sample"
)
impacts.append(
{
"table": sot_key,
"label": "样本赋值源表",
"count": 1,
}
)
pool_count = 0
if source == SOURCE_CELL:
pool_id = _norm(new_sample_code_id) or _norm(getattr(sample, "sample_code_id", None))
if pool_id:
pool_row = (
db.query(BizSampleCode)
.filter(
BizSampleCode.tenant_no == tenant_no,
BizSampleCode.del_flag == 0,
BizSampleCode.id == pool_id,
)
.first()
)
if pool_row and _norm(pool_row.sample_code) == old_code:
pool_count = 1
elif not pool_row and _norm(new_sample_code_id):
blockers.append(f"指定的新编码池 ID「{new_sample_code_id}」不存在")
existing_new_pool = (
db.query(BizSampleCode)
.filter(
BizSampleCode.tenant_no == tenant_no,
BizSampleCode.del_flag == 0,
BizSampleCode.sample_code == new_code,
)
.first()
)
if existing_new_pool and pool_count == 0:
impacts.append(
{
"table": "biz_sample_code",
"label": "编码池(切换占用)",
"count": 1,
}
)
elif pool_count:
impacts.append(
{
"table": "biz_sample_code",
"label": "编码池(就地改码)",
"count": pool_count,
}
)
for key, label, model, attr, has_rs, cell_only in _TABLE_SPECS:
if not _spec_applies(source, cell_only, key):
continue
cnt = _count_by_code(
db,
model,
tenant_no=tenant_no,
code=old_code,
code_attr=attr,
reception_source=source if has_rs else None,
has_reception_source=has_rs,
)
if cnt:
impacts.append({"table": key, "label": label, "count": cnt})
if (
key in _UNIQUE_TABLES
and source == SOURCE_CELL
and cnt > 0
and _unique_conflict(db, model, tenant_no=tenant_no, new_code=new_code)
):
blockers.append(
f"表 {key} 已存在样本编码「{new_code}」记录,无法直接改码"
)
ti = _count_task_inputs(db, tenant_no, old_code)
if ti:
impacts.append(
{
"table": "biz_production_task_input",
"label": "生产任务投入物(样本)",
"count": ti,
}
)
same_product_old = (
_count_same_product_code(db, tenant_no, old_code) if old_code else 0
)
same_product_new = (
_count_same_product_code(db, tenant_no, new_code) if new_code else 0
)
total = sum(int(i.get("count") or 0) for i in impacts)
return {
"ok": len(blockers) == 0,
"blockers": blockers,
"receptionSource": source,
"oldSampleCode": old_code,
"newSampleCode": new_code,
"sampleId": getattr(sample, "id", None) if sample else None,
"impacts": impacts,
"totalAffectedRows": total,
"hints": {
"sameProductCodeAsOld": same_product_old,
"sameProductCodeAsNew": same_product_new,
"labStorageCodeUnchanged": True,
"cellProductCodeNotAutoUpdated": True,
},
}
def _resolve_pool(
db: Session,
*,
tenant_no: str,
sample: BizProtocolSample,
old_code: str,
new_code: str,
new_sample_code_id: Optional[str],
user_id: Optional[str],
) -> Dict[str, Any]:
"""返回 pool 处理结果摘要。"""
now = _now()
result = {"action": "none", "poolId": None}
preferred_id = _norm(new_sample_code_id)
existing_new = (
db.query(BizSampleCode)
.filter(
BizSampleCode.tenant_no == tenant_no,
BizSampleCode.del_flag == 0,
BizSampleCode.sample_code == new_code,
)
.first()
)
if preferred_id:
preferred = (
db.query(BizSampleCode)
.filter(
BizSampleCode.tenant_no == tenant_no,
BizSampleCode.del_flag == 0,
BizSampleCode.id == preferred_id,
)
.first()
)
if preferred is None:
raise SampleCodeCorrectionError(f"指定的新编码池 ID「{preferred_id}」不存在")
if _norm(preferred.sample_code) not in {new_code, old_code}:
# 允许指向已是新码的池行,或指向即将就地改码的旧池行
if _norm(preferred.sample_code) != new_code:
preferred.sample_code = new_code
preferred.update_by = user_id
preferred.update_time = now
result["action"] = "rename_preferred"
existing_new = preferred
old_pool_id = _norm(getattr(sample, "sample_code_id", None))
old_pool = None
if old_pool_id:
old_pool = (
db.query(BizSampleCode)
.filter(
BizSampleCode.tenant_no == tenant_no,
BizSampleCode.del_flag == 0,
BizSampleCode.id == old_pool_id,
)
.first()
)
if existing_new is not None:
sample.sample_code_id = existing_new.id
sample.sample_code = new_code
existing_new.issued = 1
existing_new.update_by = user_id
existing_new.update_time = now
result["action"] = result["action"] if result["action"] != "none" else "reassign"
result["poolId"] = existing_new.id
if (
old_pool
and old_pool.id != existing_new.id
and _norm(old_pool.sample_code) == old_code
):
still_used = (
db.query(BizProtocolSample.id)
.filter(
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.del_flag == 0,
BizProtocolSample.sample_code_id == old_pool.id,
BizProtocolSample.id != sample.id,
)
.first()
)
if still_used is None:
old_pool.issued = 0
old_pool.update_by = user_id
old_pool.update_time = now
return result
if old_pool and _norm(old_pool.sample_code) == old_code:
old_pool.sample_code = new_code
old_pool.issued = 1
old_pool.update_by = user_id
old_pool.update_time = now
sample.sample_code = new_code
sample.sample_code_id = old_pool.id
result["action"] = "rename_inplace"
result["poolId"] = old_pool.id
return result
sample.sample_code = new_code
result["action"] = "sample_text_only"
return result
def apply_correction(
db: Session,
*,
tenant_no: str,
reception_source: str,
old_sample_code: str,
new_sample_code: str,
new_sample_code_id: Optional[str] = None,
reason: Optional[str] = None,
user_id: Optional[str] = None,
) -> Dict[str, Any]:
preview = preview_correction(
db,
tenant_no=tenant_no,
reception_source=reception_source,
old_sample_code=old_sample_code,
new_sample_code=new_sample_code,
new_sample_code_id=new_sample_code_id,
)
if not preview.get("ok"):
raise SampleCodeCorrectionError(
";".join(preview.get("blockers") or ["校验未通过"])
)
source = preview["receptionSource"]
old_code = preview["oldSampleCode"]
new_code = preview["newSampleCode"]
sample = _load_source_sample(
db, tenant_no=tenant_no, reception_source=source, sample_code=old_code
)
if sample is None:
raise SampleCodeCorrectionError(f"未找到样本编码「{old_code}」")
now = _now()
impact_counts: Dict[str, int] = {}
if source == SOURCE_CELL:
pool_info = _resolve_pool(
db,
tenant_no=tenant_no,
sample=sample,
old_code=old_code,
new_code=new_code,
new_sample_code_id=new_sample_code_id,
user_id=user_id,
)
impact_counts["biz_sample_code"] = 1 if pool_info.get("action") != "none" else 0
sample.update_by = user_id
sample.update_time = now
impact_counts["biz_protocol_sample"] = 1
else:
sample.sample_code = new_code
sample.update_by = user_id
sample.update_time = now
impact_counts["biz_research_donation_sample"] = 1
# UNIQUE 表先更新,避免其它逻辑读到半新半旧
for key, _label, model, attr, has_rs, cell_only in _TABLE_SPECS:
if key not in _UNIQUE_TABLES:
continue
if not _spec_applies(source, cell_only, key):
continue
n = _update_by_code(
db,
model,
tenant_no=tenant_no,
old_code=old_code,
new_code=new_code,
code_attr=attr,
reception_source=source if has_rs else None,
has_reception_source=has_rs,
user_id=user_id,
)
if n:
impact_counts[key] = n
for key, _label, model, attr, has_rs, cell_only in _TABLE_SPECS:
if key in _UNIQUE_TABLES:
continue
if not _spec_applies(source, cell_only, key):
continue
n = _update_by_code(
db,
model,
tenant_no=tenant_no,
old_code=old_code,
new_code=new_code,
code_attr=attr,
reception_source=source if has_rs else None,
has_reception_source=has_rs,
user_id=user_id,
)
if n:
impact_counts[key] = n
ti = _update_task_inputs(
db,
tenant_no=tenant_no,
old_code=old_code,
new_code=new_code,
user_id=user_id,
)
if ti:
impact_counts["biz_production_task_input"] = ti
log = BizSampleCodeCorrectionLog(
id=generate_uuid(),
reception_source=source,
old_sample_code=old_code,
new_sample_code=new_code,
impact_json=json.dumps(impact_counts, ensure_ascii=False),
reason=_norm(reason) or None,
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(log)
db.commit()
return {
"ok": True,
"logId": log.id,
"receptionSource": source,
"oldSampleCode": old_code,
"newSampleCode": new_code,
"impactCounts": impact_counts,
"totalAffectedRows": sum(impact_counts.values()),
"hints": preview.get("hints") or {},
}