- 送给模型的「同一行」在有框线的表格里按单元格分行 - 「以合同签订时间为准」不再被当成「签订合同时才交」,要附合同的业绩评分项照常查 - 重复的要求各自判断,问题接口给出 duplicate_ids,查标页同组折叠显示、一起处理 - 决策 C5 修订记入 decisions.md;开发集召回 74%、致命级 25/32 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
535 lines
22 KiB
Python
535 lines
22 KiB
Python
"""检查批次与问题处理(FR-M3-02、07、08,api-design §4.1、§4.5)。
|
||
|
||
- 计费(data-model §4.3):免费版不记流水;专业版 24 小时内复查免扣(记一行 0);其余在解析额度上预扣 1 次。
|
||
预览和发起共用同一个计费函数;前端带来的 expected_charge 和服务端当下算的不一致时拒绝,不扣点(API4)。
|
||
- 「预扣 → 建批次 → 入队」在同一个事务里(架构 §3.4)。
|
||
- 只有最近一次检查的问题可以处理;致命问题「确认无误」也要写说明,说明同时进误报反馈(U4)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from datetime import UTC, datetime, timedelta
|
||
from typing import Any
|
||
from uuid import UUID
|
||
|
||
from fastapi.encoders import jsonable_encoder
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from bid_doctor.audit import service as audit
|
||
from bid_doctor.billing import service as billing
|
||
from bid_doctor.check import manual, rules
|
||
from bid_doctor.check.models import CheckItemResult, CheckRun, CheckRunDocument, Finding, FindingCitation, ManualCheck
|
||
from bid_doctor.checklist import edit as checklist_edit
|
||
from bid_doctor.checklist import service as checklist
|
||
from bid_doctor.core import queue
|
||
from bid_doctor.core.db import TenantContext
|
||
from bid_doctor.core.errors import AppError
|
||
from bid_doctor.core.ids import uuid7
|
||
from bid_doctor.document import citations
|
||
from bid_doctor.document import service as documents
|
||
from bid_doctor.document.models import SourceDocument
|
||
from bid_doctor.document.pipeline.text_layer import norm
|
||
from bid_doctor.identity import service as identity
|
||
from bid_doctor.ops import service as feedback
|
||
from bid_doctor.project import service as projects
|
||
from bid_doctor.project.models import Project
|
||
|
||
CHECK_TASK = "check_project"
|
||
RULE_SET_VERSION = "2026.11-r2" # 规则集有版本,检查批次记下当时用的版本(架构 §3.7);r2:加分项 / 单价上限
|
||
WAIVE_WINDOW = timedelta(hours=24)
|
||
ERROR_MESSAGES = {
|
||
"bid_text_missing": "投标文件里几乎读不出文字(可能都是扫描件),扫描件识别还没有上线",
|
||
"file_corrupted": "投标文件无法转换,可能已加密或损坏",
|
||
"plan_limit_exceeded": "投标文件页数超出套餐上限",
|
||
"checklist_empty": "正式清单还没有条目",
|
||
"converter_unavailable": "文档转换服务暂时不可用",
|
||
"model_unavailable": "模型服务暂时不可用",
|
||
"internal_error": "系统出错了",
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class Billing:
|
||
mode: str # charged / waived_recheck / free_plan
|
||
charge: int
|
||
waive_until: datetime | None
|
||
|
||
|
||
async def billing_mode(s: AsyncSession, ctx: TenantContext, p: Project) -> Billing:
|
||
_, plan = await billing.current_plan(s, ctx)
|
||
if plan.code == "free":
|
||
return Billing("free_plan", 0, None) # 免费版只限 1 个项目,项目内检查不限次数(01 §4)
|
||
last = p.last_charged_check_at
|
||
if last is not None and datetime.now(UTC) - last < WAIVE_WINDOW:
|
||
return Billing("waived_recheck", 0, last + WAIVE_WINDOW) # 从上一次扣了额度的检查算起,不重新计时(P1)
|
||
return Billing("charged", 1, None)
|
||
|
||
|
||
async def _running(s: AsyncSession, project_id: UUID) -> CheckRun | None:
|
||
return (
|
||
await s.execute(
|
||
select(CheckRun).where(CheckRun.project_id == project_id, CheckRun.status.in_(("queued", "running")))
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
|
||
async def _tender_ready(s: AsyncSession, project_id: UUID) -> bool:
|
||
n = (
|
||
await s.execute(
|
||
select(func.count()).where(
|
||
SourceDocument.project_id == project_id,
|
||
SourceDocument.role == "tender",
|
||
SourceDocument.status == "ready",
|
||
)
|
||
)
|
||
).scalar_one()
|
||
return n > 0
|
||
|
||
|
||
async def preview(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> dict[str, Any]:
|
||
p = await projects.get(s, ctx, project_id)
|
||
mode = await billing_mode(s, ctx, p)
|
||
bids = await documents.latest_bid_volumes(s, project_id)
|
||
blockers: list[str] = []
|
||
if p.status in ("submitted", "archived"):
|
||
blockers.append("project_read_only")
|
||
if not await _tender_ready(s, project_id):
|
||
blockers.append("no_parsed_tender")
|
||
elif not await checklist.items(s, project_id, zone="formal"):
|
||
blockers.append("checklist_empty")
|
||
if not bids:
|
||
blockers.append("no_bid_documents")
|
||
if await _running(s, project_id):
|
||
blockers.append("job_already_running")
|
||
if mode.charge and await billing.balance(s, ctx, "parse") < 1:
|
||
blockers.append("quota_exhausted")
|
||
states = await checklist.statuses(s, [project_id])
|
||
return {
|
||
"can_start": not blockers,
|
||
"blockers": blockers,
|
||
"billing": mode.mode,
|
||
"charge": mode.charge,
|
||
"charge_unit": "parse",
|
||
"waive_until": mode.waive_until,
|
||
"checklist_confirmed": states.get(project_id) == "confirmed",
|
||
"pending_item_count": (await checklist.pending_counts(s, [project_id])).get(project_id, 0),
|
||
"bid_documents": [await documents.doc_to_api(s, d) for d in bids],
|
||
"lot_ids": await projects.bidding_lot_ids(s, project_id),
|
||
}
|
||
|
||
|
||
async def start(
|
||
s: AsyncSession, ctx: TenantContext, project_id: UUID, document_ids: list[UUID], expected_charge: int
|
||
) -> CheckRun:
|
||
p = await projects.get(s, ctx, project_id)
|
||
projects.ensure_editable(p)
|
||
if running := await _running(s, project_id):
|
||
raise AppError("job_already_running", job_id=str(running.id))
|
||
if not await _tender_ready(s, project_id):
|
||
raise AppError("no_parsed_tender")
|
||
docs = (
|
||
(await s.execute(select(SourceDocument).where(SourceDocument.id.in_(document_ids)))).scalars().all()
|
||
if document_ids
|
||
else []
|
||
)
|
||
if not docs or len(docs) != len(set(document_ids)):
|
||
raise AppError("no_bid_documents")
|
||
if any(d.project_id != project_id or d.role != "bid_volume" for d in docs):
|
||
raise AppError("not_found")
|
||
mode = await billing_mode(s, ctx, p)
|
||
if expected_charge != mode.charge:
|
||
raise AppError("billing_changed", preview=jsonable_encoder(await preview(s, ctx, project_id)))
|
||
version, confirmed = await checklist_edit.check_snapshot(s, ctx, project_id)
|
||
run_no = (
|
||
await s.execute(
|
||
select(func.coalesce(func.max(CheckRun.run_no), 0) + 1).where(CheckRun.project_id == project_id)
|
||
)
|
||
).scalar_one()
|
||
run = CheckRun(
|
||
id=uuid7(),
|
||
tenant_id=ctx.tenant_id,
|
||
project_id=project_id,
|
||
run_no=run_no,
|
||
checklist_version_id=version.id,
|
||
checklist_confirmed=confirmed,
|
||
rule_set_version=RULE_SET_VERSION,
|
||
legal_regime=p.legal_regime,
|
||
lot_ids=await projects.bidding_lot_ids(s, project_id),
|
||
previous_run_id=p.latest_check_run_id,
|
||
billing=mode.mode,
|
||
created_by=ctx.user_id,
|
||
)
|
||
if mode.mode == "charged":
|
||
run.quota_hold_id = await billing.hold(
|
||
s, ctx, unit="parse", ref_type="check_run", ref_id=run.id, project_id=project_id
|
||
)
|
||
s.add(run)
|
||
await s.flush()
|
||
for d in docs:
|
||
s.add(CheckRunDocument(tenant_id=ctx.tenant_id, check_run_id=run.id, document_id=d.id))
|
||
if mode.mode == "waived_recheck":
|
||
await billing.waive(
|
||
s, ctx, unit="parse", ref_type="check_run", ref_id=run.id, project_id=project_id, reason="recheck_24h"
|
||
)
|
||
run.queue_job_id = await queue.enqueue(
|
||
s, CHECK_TASK, {"tenant_id": str(ctx.tenant_id), "run_id": str(run.id)}, lock=f"project:{project_id}"
|
||
)
|
||
await audit.record(
|
||
s,
|
||
ctx,
|
||
"check_run.start",
|
||
"check_run",
|
||
run.id,
|
||
project_id=project_id,
|
||
after={"run_no": run_no, "billing": mode.mode, "documents": [str(d.id) for d in docs]},
|
||
)
|
||
await s.flush()
|
||
await s.refresh(run)
|
||
return run
|
||
|
||
|
||
async def get_run(s: AsyncSession, run_id: UUID) -> CheckRun:
|
||
run = (await s.execute(select(CheckRun).where(CheckRun.id == run_id))).scalar_one_or_none()
|
||
if run is None:
|
||
raise AppError("not_found")
|
||
return run
|
||
|
||
|
||
async def request_cancel(s: AsyncSession, run_id: UUID) -> CheckRun:
|
||
run = await get_run(s, run_id)
|
||
if run.status not in ("queued", "running"):
|
||
raise AppError("job_finished")
|
||
run.cancel_requested_at = datetime.now(UTC)
|
||
await s.flush()
|
||
return run
|
||
|
||
|
||
async def list_runs(s: AsyncSession, project_id: UUID) -> list[CheckRun]:
|
||
rows = await s.execute(select(CheckRun).where(CheckRun.project_id == project_id).order_by(CheckRun.run_no.desc()))
|
||
return list(rows.scalars())
|
||
|
||
|
||
async def run_to_api(s: AsyncSession, ctx: TenantContext, run: CheckRun) -> dict[str, Any]:
|
||
p = await projects.get(s, ctx, run.project_id)
|
||
doc_ids = list(
|
||
(await s.execute(select(CheckRunDocument.document_id).where(CheckRunDocument.check_run_id == run.id))).scalars()
|
||
)
|
||
docs = (await s.execute(select(SourceDocument).where(SourceDocument.id.in_(doc_ids)))).scalars().all()
|
||
version_no = (await checklist_edit.version_numbers(s, [run.checklist_version_id])).get(run.checklist_version_id, 0)
|
||
names = await identity.display_names(s, [run.created_by])
|
||
error = None
|
||
if run.status in ("failed", "cancelled") and run.error_code:
|
||
error = {
|
||
"code": run.error_code,
|
||
"message": ERROR_MESSAGES.get(run.error_code, run.error_code),
|
||
"refunded": run.billing == "charged",
|
||
}
|
||
return {
|
||
"id": run.id,
|
||
"run_no": run.run_no,
|
||
"status": run.status,
|
||
"stage": run.stage,
|
||
"progress": run.progress,
|
||
"cancel_requested": run.cancel_requested_at is not None,
|
||
"error": error,
|
||
"billing": run.billing,
|
||
"is_latest": p.latest_check_run_id == run.id,
|
||
"checklist_version_no": version_no,
|
||
"checklist_confirmed": run.checklist_confirmed,
|
||
"rule_set_version": run.rule_set_version,
|
||
"legal_regime": run.legal_regime,
|
||
"documents": [await documents.doc_to_api(s, d) for d in docs],
|
||
"coverage": run.coverage,
|
||
"counts": run.counts,
|
||
"created_by": {"id": run.created_by, "name": names.get(run.created_by, "")},
|
||
"created_at": run.created_at,
|
||
"finished_at": run.finished_at,
|
||
}
|
||
|
||
|
||
# ------------------------------------------------------------------ 问题
|
||
|
||
|
||
async def _items_of(s: AsyncSession, run: CheckRun) -> dict[UUID, dict[str, Any]]:
|
||
return await checklist_edit.version_items(s, run.checklist_version_id)
|
||
|
||
|
||
async def findings(
|
||
s: AsyncSession, run_id: UUID, *, severity: str | None, status: str | None, presence: str | None
|
||
) -> list[dict[str, Any]]:
|
||
run = await get_run(s, run_id)
|
||
q = select(Finding).where(Finding.check_run_id == run_id)
|
||
if severity:
|
||
q = q.where(Finding.severity == severity)
|
||
if status:
|
||
q = q.where(Finding.status == status)
|
||
if presence:
|
||
q = q.where(Finding.presence == presence)
|
||
rows = list((await s.execute(q)).scalars())
|
||
return await _to_api_many(s, run, rows)
|
||
|
||
|
||
ORDER = {"fatal": 0, "important": 1, "hint": 2}
|
||
|
||
|
||
async def _to_api_many(s: AsyncSession, run: CheckRun, rows: list[Finding]) -> list[dict[str, Any]]:
|
||
"""按级别、再按招标原文顺序(清单项的排序键)。"""
|
||
items = await _items_of(s, run)
|
||
links = (
|
||
await s.execute(
|
||
select(FindingCitation)
|
||
.where(FindingCitation.finding_id.in_([f.id for f in rows]))
|
||
.order_by(FindingCitation.position)
|
||
)
|
||
).scalars()
|
||
by_finding: dict[UUID, list[FindingCitation]] = {}
|
||
for link in links:
|
||
by_finding.setdefault(link.finding_id, []).append(link)
|
||
cites = await citations.to_api_many(s, [link.citation_id for ls in by_finding.values() for link in ls])
|
||
names = await identity.display_names(s, [f.resolved_by for f in rows if f.resolved_by])
|
||
sort_keys = await checklist.sort_keys(s, [f.checklist_item_id for f in rows if f.checklist_item_id])
|
||
# 重复的要求(决策 C5 修订):每条照常单独判断,问题类型相同、要求原文完全相同的折叠显示
|
||
groups: dict[tuple[str, str], list[UUID]] = {}
|
||
for f in rows:
|
||
item = items.get(f.checklist_item_id) if f.checklist_item_id else None
|
||
text = norm(str((item or {}).get("requirement_text") or (item or {}).get("title") or ""))
|
||
if item and f.presence != "resolved" and len(text) >= 6:
|
||
groups.setdefault((f.type, text), []).append(f.id)
|
||
same = {fid: [x for x in ids if x != fid] for ids in groups.values() if len(ids) > 1 for fid in ids}
|
||
|
||
def api(f: Finding) -> dict[str, Any]:
|
||
item = items.get(f.checklist_item_id) if f.checklist_item_id else None
|
||
ls = by_finding.get(f.id, [])
|
||
return {
|
||
"id": f.id,
|
||
"type": f.type,
|
||
"severity": f.severity,
|
||
"title": f.title,
|
||
"explanation": f.explanation,
|
||
"detector": f.detector,
|
||
"checklist_item": {
|
||
"id": f.checklist_item_id,
|
||
"title": item.get("title"),
|
||
"importance": item.get("importance"),
|
||
"categories": item.get("categories"),
|
||
}
|
||
if item
|
||
else None,
|
||
"rule": {"code": f.rule_code, "description": rules.LABELS.get(f.rule_code, "")}
|
||
if f.rule_code and not item
|
||
else None,
|
||
"detail": f.detail,
|
||
"tender_citations": [cites[x.citation_id] for x in ls if x.side == "tender" and x.citation_id in cites],
|
||
"bid_citations": [cites[x.citation_id] for x in ls if x.side == "bid" and x.citation_id in cites],
|
||
"search_scope": f.search_scope,
|
||
"duplicate_ids": same.get(f.id, []),
|
||
"presence": f.presence,
|
||
"carried_status": (f.detail or {}).get("carried_status"),
|
||
"status": f.status,
|
||
"resolution_note": f.resolution_note,
|
||
"resolved_by": {"id": f.resolved_by, "name": names.get(f.resolved_by, "")} if f.resolved_by else None,
|
||
"resolved_at": f.resolved_at,
|
||
}
|
||
|
||
rows.sort(key=lambda f: (ORDER[f.severity], sort_keys.get(f.checklist_item_id or UUID(int=0), "~"), f.title))
|
||
return [api(f) for f in rows]
|
||
|
||
|
||
async def _get_finding(s: AsyncSession, finding_id: UUID) -> Finding:
|
||
f = (await s.execute(select(Finding).where(Finding.id == finding_id))).scalar_one_or_none()
|
||
if f is None:
|
||
raise AppError("not_found")
|
||
return f
|
||
|
||
|
||
async def _editable_run(s: AsyncSession, ctx: TenantContext, run_id: UUID) -> tuple[CheckRun, Project]:
|
||
run = await get_run(s, run_id)
|
||
p = await projects.get(s, ctx, run.project_id)
|
||
projects.ensure_editable(p)
|
||
if p.latest_check_run_id != run.id:
|
||
raise AppError("run_superseded")
|
||
return run, p
|
||
|
||
|
||
async def open_fatal(s: AsyncSession, run_id: UUID) -> int:
|
||
return (
|
||
await s.execute(
|
||
select(func.count()).where(
|
||
Finding.check_run_id == run_id,
|
||
Finding.severity == "fatal",
|
||
Finding.status == "pending",
|
||
Finding.presence != "resolved",
|
||
)
|
||
)
|
||
).scalar_one()
|
||
|
||
|
||
def _require_note(severity: str, status: str, note: str | None) -> None:
|
||
if (status == "ignored" or (severity == "fatal" and status == "confirmed_ok")) and not (note or "").strip():
|
||
raise AppError("note_required")
|
||
|
||
|
||
async def _apply(s: AsyncSession, ctx: TenantContext, f: Finding, p: Project, status: str, note: str | None) -> None:
|
||
before = {"status": f.status, "note": f.resolution_note}
|
||
done = status != "pending"
|
||
f.status = status
|
||
f.resolution_note = ((note or "").strip() or None) if done else None
|
||
f.resolved_by, f.resolved_at = (ctx.user_id, datetime.now(UTC)) if done else (None, None)
|
||
if f.severity == "fatal" and status == "confirmed_ok":
|
||
await feedback.record(
|
||
s,
|
||
ctx,
|
||
project_id=p.id,
|
||
object_type="finding",
|
||
object_id=f.id,
|
||
kind="false_positive",
|
||
source="fatal_confirm",
|
||
note=f.resolution_note,
|
||
)
|
||
await audit.record(
|
||
s,
|
||
ctx,
|
||
"finding.resolve" if done else "finding.reopen",
|
||
"finding",
|
||
f.id,
|
||
project_id=p.id,
|
||
before=before,
|
||
after={"status": f.status, "note": f.resolution_note},
|
||
)
|
||
|
||
|
||
async def resolve(
|
||
s: AsyncSession, ctx: TenantContext, finding_id: UUID, status: str, note: str | None
|
||
) -> dict[str, Any]:
|
||
f = await _get_finding(s, finding_id)
|
||
run, p = await _editable_run(s, ctx, f.check_run_id)
|
||
if f.presence == "resolved":
|
||
raise AppError("finding_resolved_this_run")
|
||
_require_note(f.severity, status, note)
|
||
await _apply(s, ctx, f, p, status, note)
|
||
await s.flush()
|
||
p.open_fatal_count = await open_fatal(s, run.id)
|
||
await s.flush()
|
||
(out,) = await _to_api_many(s, run, [f])
|
||
return {"finding": out, "project": projects.readiness(p)}
|
||
|
||
|
||
async def reopen(s: AsyncSession, ctx: TenantContext, finding_id: UUID) -> dict[str, Any]:
|
||
f = await _get_finding(s, finding_id)
|
||
run, p = await _editable_run(s, ctx, f.check_run_id)
|
||
if f.presence == "resolved":
|
||
raise AppError("finding_resolved_this_run")
|
||
await _apply(s, ctx, f, p, "pending", None)
|
||
await s.flush()
|
||
p.open_fatal_count = await open_fatal(s, run.id)
|
||
await s.flush()
|
||
(out,) = await _to_api_many(s, run, [f])
|
||
return {"finding": out, "project": projects.readiness(p)}
|
||
|
||
|
||
async def bulk_resolve(
|
||
s: AsyncSession, ctx: TenantContext, run_id: UUID, finding_ids: list[UUID], status: str, note: str | None
|
||
) -> dict[str, Any]:
|
||
"""只限「提示」级(FR-M3-07);混进了别的级别就整批拒绝,返回不符合的问题编号。"""
|
||
run, p = await _editable_run(s, ctx, run_id)
|
||
rows = list((await s.execute(select(Finding).where(Finding.id.in_(finding_ids)))).scalars())
|
||
if len(rows) != len(set(finding_ids)) or any(f.check_run_id != run.id for f in rows):
|
||
raise AppError("not_found")
|
||
bad = [str(f.id) for f in rows if f.severity != "hint" or f.presence == "resolved"]
|
||
if bad:
|
||
raise AppError("bulk_only_hint", finding_ids=bad)
|
||
_require_note("hint", status, note)
|
||
for f in rows:
|
||
await _apply(s, ctx, f, p, status, note)
|
||
await s.flush()
|
||
p.open_fatal_count = await open_fatal(s, run.id)
|
||
await s.flush()
|
||
return {"items": await _to_api_many(s, run, rows), "project": projects.readiness(p)}
|
||
|
||
|
||
async def item_results(s: AsyncSession, run_id: UUID) -> list[dict[str, Any]]:
|
||
"""每条清单项的检查结论(C9):「已响应」也有出处,可以复核、可以反馈判断有误。"""
|
||
run = await get_run(s, run_id)
|
||
items = await _items_of(s, run)
|
||
rows = list((await s.execute(select(CheckItemResult).where(CheckItemResult.check_run_id == run_id))).scalars())
|
||
cites = await citations.to_api_many(s, [c for r in rows for c in r.bid_citation_ids])
|
||
return [
|
||
{
|
||
"checklist_item_id": r.checklist_item_id,
|
||
"title": (items.get(r.checklist_item_id) or {}).get("title"),
|
||
"verdict": r.verdict,
|
||
"layer": r.layer,
|
||
"evidence_status": r.evidence_status,
|
||
"reason_code": r.reason_code,
|
||
"bid_citations": [cites[c] for c in r.bid_citation_ids if c in cites],
|
||
"detail": r.detail,
|
||
"reused": r.reused_from_run_id is not None,
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
async def manual_checks(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> list[dict[str, Any]]:
|
||
"""需人工核对的条目(按主题合并,来自当前正式清单)和勾选结果(按项目存,复查沿用,P2)。"""
|
||
await projects.get(s, ctx, project_id)
|
||
topics = manual.topics(await checklist.items(s, project_id, zone="formal"))
|
||
rows = {
|
||
r.check_key: r
|
||
for r in (await s.execute(select(ManualCheck).where(ManualCheck.project_id == project_id))).scalars()
|
||
}
|
||
names = await identity.display_names(s, [r.checked_by for r in rows.values() if r.checked])
|
||
out = []
|
||
for t in topics:
|
||
first = t.items[0]
|
||
r = rows.get(t.check_key)
|
||
done = bool(r and r.checked)
|
||
out.append(
|
||
{
|
||
"check_key": t.check_key,
|
||
"label": t.label,
|
||
"requirement_text": first.get("title"),
|
||
"citation": (first.get("citations") or [None])[0],
|
||
"related_items": [{"id": i["id"], "title": i["title"]} for i in t.items],
|
||
"checked": done,
|
||
"note": r.note if r else None,
|
||
"checked_by": {"id": r.checked_by, "name": names.get(r.checked_by, "")} if r and done else None,
|
||
"checked_at": r.checked_at if r and done else None,
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
async def set_manual_check(
|
||
s: AsyncSession, ctx: TenantContext, project_id: UUID, check_key: str, checked: bool, note: str | None
|
||
) -> dict[str, Any]:
|
||
p = await projects.get(s, ctx, project_id)
|
||
projects.ensure_editable(p)
|
||
current = {x["check_key"]: x for x in await manual_checks(s, ctx, project_id)}
|
||
if check_key not in current:
|
||
raise AppError("validation_failed", errors=[{"field": "check_key", "code": "unknown", "message": "没有这一条"}])
|
||
r = (
|
||
await s.execute(
|
||
select(ManualCheck).where(ManualCheck.project_id == project_id, ManualCheck.check_key == check_key)
|
||
)
|
||
).scalar_one_or_none()
|
||
before = {"checked": r.checked, "note": r.note} if r else None
|
||
note = (note or "").strip() or None
|
||
if r is None:
|
||
r = ManualCheck(tenant_id=ctx.tenant_id, project_id=project_id, check_key=check_key, checked_by=ctx.user_id)
|
||
s.add(r)
|
||
r.checked, r.note, r.checked_by, r.checked_at = checked, note, ctx.user_id, datetime.now(UTC)
|
||
await audit.record(
|
||
s,
|
||
ctx,
|
||
"manual_check.set",
|
||
"project",
|
||
project_id,
|
||
project_id=project_id,
|
||
before=before,
|
||
after={"check_key": check_key, "checked": checked, "note": note},
|
||
)
|
||
await s.flush()
|
||
return next(x for x in await manual_checks(s, ctx, project_id) if x["check_key"] == check_key)
|