feat(project): 项目基本信息修改和分包选择(FR-M2-03、04)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

- 字段修改:人工值优先、撤销恢复 AI 值、按字段校验(金额可写「286万」,按元存)、
  冗余列同步、审计留痕、只读项目 409;关键日期显示改后的投标截止时间
- 分包:增删改、选包(至少一个)、一键「不分包」;删包时条目归为通用,删到一个不剩回到不分包
- 清单列表 lot_scope 默认只列通用 + 所投的包,响应带 hidden_count
- 检查记下当时用的项目依据(截止时间、限价、所投的包、法律体系):报告按它写,
  之后改了信息检查结果接口给 basis_changed,查标页提示重新检查
- 前端:概览标签页(项目要点、采购包、关键日期)、字段修改对话框、选包页和手工调整;
  解析完识别到多个包时进选包页;读标页清单范围和所属采购包;查标页写明查哪几个包
- 决策 PI1–PI7;方案和落地记录 docs/architecture/project-info-plan.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 23:46:07 +08:00
parent 08e3fe7eff
commit ed6a2a907d
29 changed files with 1834 additions and 43 deletions

View File

@@ -173,6 +173,7 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
facts = await _facts(s, ctx, r, snapshot)
prior = await _prior(s, r)
library = await qualifications.records(s)
basis = await projects.basis(s, r.project_id) # 检查时用的项目依据(PI5)
# 招标文件按类别的响应方式(C12):用清单里存的(人工改过的为准);解析早于这项功能的项目临时识别
modes = response_mode.from_stored(stored_modes) if stored_modes else response_mode.detect(tender_sents)
bidding = set(r.lot_ids)
@@ -244,7 +245,7 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
r.counts = await _counts(s, run_id)
r.coverage = _coverage(
r, pages_info, items, results, pending, other_lot, stats, tokens, skipped, modes, rule_cov, qual_cov
)
) | {"basis": basis}
await _publish(ctx, r, "running", "summarize", PROGRESS["summarize"])

View File

@@ -212,6 +212,12 @@ async def list_runs(s: AsyncSession, project_id: UUID) -> list[CheckRun]:
return list(rows.scalars())
async def _basis_changed(s: AsyncSession, run: CheckRun) -> bool:
"""检查之后改过项目依据(截止时间、限价、所投的包、法律体系):查标页提示重新检查后生效(PI5)。"""
saved = (run.coverage or {}).get("basis")
return saved is not None and saved != await projects.basis(s, run.project_id)
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(
@@ -243,6 +249,8 @@ async def run_to_api(s: AsyncSession, ctx: TenantContext, run: CheckRun) -> dict
"legal_regime": run.legal_regime,
"documents": [await documents.doc_to_api(s, d) for d in docs],
"coverage": run.coverage,
# 检查之后改过项目依据(截止时间、限价、所投的包、法律体系):查标页提示重新检查后生效(PI5)
"basis_changed": await _basis_changed(s, run),
"counts": run.counts,
"created_by": {"id": run.created_by, "name": names.get(run.created_by, "")},
"created_at": run.created_at,

View File

@@ -63,11 +63,16 @@ async def list_items(
zone: Literal["pending", "formal"] | None = None,
category: Category | None = None,
include_deleted: bool = False,
lot_scope: Literal["bidding", "all"] = "bidding",
) -> dict[str, Any]:
# TODO(分包):lot_scope(默认只列通用 + 所投的包),选包做好之后加
async with tenant_tx(ctx) as s:
await projects.get(s, ctx, project_id)
return {"items": await service.items(s, project_id, zone, category, include_deleted)}
rows = await service.items(s, project_id, zone, category, include_deleted)
if lot_scope == "all":
return {"items": rows, "hidden_count": 0}
bidding = set(await projects.bidding_lot_ids(s, project_id)) # 通用 + 所投的包(FR-M2-04);不分包时为空
shown = [r for r in rows if not bidding or r["lot_id"] is None or r["lot_id"] in bidding]
return {"items": shown, "hidden_count": len(rows) - len(shown)}
@router.post("/projects/{project_id}/checklist/items", status_code=201)

View File

@@ -409,6 +409,7 @@ async def key_dates(s: AsyncSession, project_id: UUID) -> list[dict[str, Any]]:
{
"item_id": r.id,
"label": labels.get(sched.get("date_kind", "other"), "其他日期"),
"kind": sched.get("date_kind", "other"),
"at": sched["at"],
"superseded_pending": r.superseded_by_id is not None,
}

View File

@@ -1,16 +1,16 @@
from __future__ import annotations
from typing import Any, Literal
from typing import Annotated, Any, Literal
from uuid import UUID
from fastapi import APIRouter, Query
from fastapi import APIRouter, Header, Query
from pydantic import BaseModel, Field
from sqlalchemy import select
from bid_doctor.core.db import tenant_tx
from bid_doctor.document.models import SourceDocument
from bid_doctor.identity.deps import Ctx
from bid_doctor.project import service, workbench
from bid_doctor.project import edit, service, workbench
router = APIRouter(tags=["projects"])
@@ -23,6 +23,38 @@ class TransitionIn(BaseModel):
action: Literal["submit", "unsubmit", "archive", "unarchive"]
FieldKey = Literal[
"name",
"code",
"purchaser",
"agency",
"procurement_method",
"legal_regime",
"budget_total",
"ceiling_total",
"eval_method",
"accept_consortium",
"accept_imported",
"sme_only",
"deadline_at",
]
class FieldIn(BaseModel):
value: str | bool | None
class LotIn(BaseModel):
lot_no: str | None = Field(default=None, max_length=40)
name: str | None = Field(default=None, max_length=200)
budget_amount: str | None = None
ceiling_amount: str | None = None
class BiddingIn(BaseModel):
lot_ids: list[UUID] = Field(min_length=1)
@router.get("/projects")
async def list_projects(ctx: Ctx, limit: int = Query(default=20, ge=1, le=100)) -> dict[str, Any]:
# TODO(工作台筛选):status / deadline / q / 游标分页,按 openapi.yaml 补齐
@@ -59,3 +91,59 @@ async def transition_project(project_id: UUID, body: TransitionIn, ctx: Ctx) ->
).scalar_one_or_none() is not None
await service.transition(s, ctx, project_id, body.action, parsed)
return await workbench.detail(s, ctx, project_id)
@router.put("/projects/{project_id}/fields/{field_key}")
async def set_project_field(
project_id: UUID,
field_key: FieldKey,
body: FieldIn,
ctx: Ctx,
if_match: Annotated[str | None, Header()] = None, # 选填,和清单项修改一致(MVP 只有一个人用,先不强制)
) -> dict[str, Any]:
del if_match
async with tenant_tx(ctx) as s:
await edit.set_field(s, ctx, project_id, field_key, body.value)
return next(
(f for f in await service.fields(s, project_id) if f["key"] == field_key),
{"key": field_key, "value": None, "source": "none", "ai_value": None, "citation": None, "updated_by": None},
)
@router.get("/projects/{project_id}/lots")
async def list_lots(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
await service.get(s, ctx, project_id)
return {"items": [edit.lot_to_api(x) for x in await edit.lots(s, project_id)]}
@router.post("/projects/{project_id}/lots", status_code=201)
async def create_lot(project_id: UUID, body: LotIn, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return edit.lot_to_api(await edit.create_lot(s, ctx, project_id, body.model_dump(exclude_unset=True)))
@router.post("/projects/{project_id}/lots/clear")
async def clear_lots(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
await edit.clear_lots(s, ctx, project_id)
return {"items": [edit.lot_to_api(x) for x in await edit.lots(s, project_id)]}
@router.put("/projects/{project_id}/bidding-lots")
async def set_bidding_lots(project_id: UUID, body: BiddingIn, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
await edit.set_bidding(s, ctx, project_id, body.lot_ids)
return {"items": [edit.lot_to_api(x) for x in await edit.lots(s, project_id)]}
@router.patch("/lots/{lot_id}")
async def update_lot(lot_id: UUID, body: LotIn, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return edit.lot_to_api(await edit.update_lot(s, ctx, lot_id, body.model_dump(exclude_unset=True)))
@router.delete("/lots/{lot_id}", status_code=204)
async def delete_lot(lot_id: UUID, ctx: Ctx) -> None:
async with tenant_tx(ctx) as s:
await edit.delete_lot(s, ctx, lot_id)

View File

@@ -0,0 +1,293 @@
"""修改项目基本信息和分包(FR-M2-03、04,project-info-plan §2、§3)。
- 字段:人工值和 AI 值分开存,人工值优先;`value = null` 撤销人工值、恢复 AI 值;project 表上冗余的几列同步;
- 分包:没分包的项目有一个默认包(界面不显示);手工加第一个包时删掉默认包,删掉最后一个包时建回默认包(PI2);
删包时原属这个包的清单项归为通用(外键 ON DELETE SET NULL,PI3);
- 每次修改都写审计(全程留痕,红线 1);项目已递交、已归档时只读。
"""
from __future__ import annotations
import re
from datetime import datetime
from decimal import Decimal, InvalidOperation
from typing import Any
from uuid import UUID
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.audit import service as audit
from bid_doctor.core.authz import Permission, require
from bid_doctor.core.db import TenantContext
from bid_doctor.core.errors import AppError
from bid_doctor.core.ids import uuid7
from bid_doctor.project import service
from bid_doctor.project.models import Lot, Project, ProjectField
COLUMNS = ("name", "code", "purchaser", "agency", "procurement_method", "legal_regime") # project 表上冗余的列
TEXT = ("name", "code", "purchaser", "agency", "procurement_method", "eval_method")
MONEY = ("budget_total", "ceiling_total")
BOOL = ("accept_consortium", "accept_imported", "sme_only")
REGIMES = ("gov_procurement", "tendering", "unknown")
DEFAULT_LOT_NO = "默认"
LOT_FIELDS = ("lot_no", "name", "budget_amount", "ceiling_amount")
def _invalid(field: str, message: str) -> AppError:
return AppError("validation_failed", errors=[{"field": field, "code": "invalid", "message": message}])
def money(value: Any, field: str = "value") -> str | None:
"""金额 → 元(字符串,两位小数)。接受「2,860,000」「286万」「286 万元」(PI7)。
数字是用户自己填的(招标文件原文),不是系统给的。"""
if value in (None, ""):
return None
text = re.sub(r"[\s,,]", "", str(value))
wan = bool(re.search(r"万(元)?$", text))
text = re.sub(r"万?元?$", "", text)
try:
n = Decimal(text)
except InvalidOperation as e:
raise _invalid(field, "金额写法不对,例如:2,860,000 或 286万") from e
if n < 0 or not n.is_finite():
raise _invalid(field, "金额不能是负数")
return f"{n * 10000 if wan else n:.2f}"
def field_value(key: str, value: Any) -> Any:
"""按字段校验、规整人工值;返回 None 表示撤销人工值。"""
if value is None:
return None
if key in TEXT:
if not isinstance(value, str):
raise _invalid(key, "应为文本")
text = value.strip()
if key == "name" and not text:
raise _invalid(key, "项目名称不能为空")
return text[:500] or None
if key == "legal_regime":
if value not in REGIMES:
raise _invalid(key, "法律体系只能是政府采购、招标投标或待识别")
return value
if key in MONEY:
return money(value, key)
if key in BOOL:
if not isinstance(value, bool):
raise _invalid(key, "应为是或否")
return value
if key == "deadline_at":
try:
at = datetime.fromisoformat(str(value))
except ValueError as e:
raise _invalid(key, "时间写法不对,例如:2026-11-20T09:30:00+08:00") from e
if at.tzinfo is None:
raise _invalid(key, "时间要带时区")
return at.isoformat()
raise _invalid(key, "不能修改这个字段")
def _sync(p: Project, key: str, value: Any) -> None:
"""project 表上冗余的当前值(工作台排序、筛选用)。"""
if key in COLUMNS:
setattr(p, key, value if value is not None or key != "legal_regime" else "unknown")
elif key == "deadline_at":
p.deadline_at = datetime.fromisoformat(value) if value else None
async def set_field(s: AsyncSession, ctx: TenantContext, project_id: UUID, key: str, value: Any) -> None:
require(ctx, Permission.PROJECT_EDIT)
p = await service.get(s, ctx, project_id)
service.ensure_editable(p)
human = field_value(key, value)
f = (
await s.execute(select(ProjectField).where(ProjectField.project_id == p.id, ProjectField.field_key == key))
).scalar_one_or_none()
if f is None:
if human is None:
return
f = ProjectField(tenant_id=ctx.tenant_id, project_id=p.id, field_key=key)
s.add(f)
before = {"value": f.human_value if f.human_value is not None else f.ai_value, "source": _source(f)}
f.human_value = human
f.human_set_by, f.human_set_at = (ctx.user_id, func.now()) if human is not None else (None, None)
current = human if human is not None else f.ai_value
if key == "name" and current is None:
current = p.name # 名称没有 AI 值时撤销人工值,保持现在的名称
_sync(p, key, current)
await s.flush()
after = {"value": current, "source": "human" if human is not None else _source(f)}
await audit.record(
s, ctx, "project_field.set", "project", p.id, project_id=p.id, before={key: before}, after={key: after}
)
def _source(f: ProjectField) -> str:
return "human" if f.human_value is not None else "ai" if f.ai_value is not None else "none"
# ---------------------------------------------------------------- 分包
def lot_to_api(lot: Lot) -> dict[str, Any]:
return {
"id": lot.id,
"lot_no": lot.lot_no,
"name": lot.name,
"budget_amount": f"{lot.budget_amount:.2f}" if lot.budget_amount is not None else None,
"ceiling_amount": f"{lot.ceiling_amount:.2f}" if lot.ceiling_amount is not None else None,
"is_default": lot.is_default,
"is_bidding": lot.is_bidding,
"origin": lot.origin,
"edited_fields": lot.edited_fields,
"ai_values": lot.ai_values,
}
async def lots(s: AsyncSession, project_id: UUID) -> list[Lot]:
return list(
(await s.execute(select(Lot).where(Lot.project_id == project_id).order_by(Lot.sort_order, Lot.lot_no)))
.scalars()
.all()
)
async def _editable(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> Project:
require(ctx, Permission.PROJECT_EDIT)
p = await service.get(s, ctx, project_id)
service.ensure_editable(p)
return p
def _lot_values(values: dict[str, Any]) -> dict[str, Any]:
out: dict[str, Any] = {}
if "lot_no" in values:
no = (values["lot_no"] or "").strip()
if not no:
raise _invalid("lot_no", "包号不能为空")
out["lot_no"] = no[:40]
if "name" in values:
out["name"] = (values["name"] or "").strip()[:200] or None
for k in ("budget_amount", "ceiling_amount"):
if k in values:
v = money(values[k], k)
out[k] = Decimal(v) if v is not None else None
return out
async def _unique_no(s: AsyncSession, project_id: UUID, lot_no: str, exclude: UUID | None = None) -> None:
q = select(Lot.id).where(Lot.project_id == project_id, Lot.lot_no == lot_no)
if exclude:
q = q.where(Lot.id != exclude)
if (await s.execute(q)).first():
raise _invalid("lot_no", f"已经有「{lot_no}」这个包")
def _audit_lot(lot: Lot) -> dict[str, Any]:
return {k: str(v) if isinstance(v, Decimal) else v for k, v in ((f, getattr(lot, f)) for f in LOT_FIELDS)}
async def create_lot(s: AsyncSession, ctx: TenantContext, project_id: UUID, values: dict[str, Any]) -> Lot:
p = await _editable(s, ctx, project_id)
data = _lot_values(values)
if "lot_no" not in data:
raise _invalid("lot_no", "包号不能为空")
await s.execute(delete(Lot).where(Lot.project_id == p.id, Lot.is_default)) # 手工分包:默认包不要了
await _unique_no(s, p.id, data["lot_no"])
order = (
await s.execute(select(func.coalesce(func.max(Lot.sort_order), -1)).where(Lot.project_id == p.id))
).scalar_one()
lot = Lot(id=uuid7(), tenant_id=ctx.tenant_id, project_id=p.id, origin="human", sort_order=order + 1, **data)
lot.edited_fields = []
s.add(lot)
await s.flush()
await audit.record(s, ctx, "lot.create", "lot", lot.id, project_id=p.id, after=_audit_lot(lot))
await s.refresh(lot)
return lot
async def get_lot(s: AsyncSession, lot_id: UUID) -> Lot:
lot = (await s.execute(select(Lot).where(Lot.id == lot_id))).scalar_one_or_none()
if lot is None or lot.is_default:
raise AppError("not_found")
return lot
async def update_lot(s: AsyncSession, ctx: TenantContext, lot_id: UUID, values: dict[str, Any]) -> Lot:
lot = await get_lot(s, lot_id)
await _editable(s, ctx, lot.project_id)
data = _lot_values(values)
if "lot_no" in data:
await _unique_no(s, lot.project_id, data["lot_no"], exclude=lot.id)
before = _audit_lot(lot)
changed = [k for k, v in data.items() if getattr(lot, k) != v]
for k in changed:
setattr(lot, k, data[k])
if lot.origin == "ai":
lot.edited_fields = sorted({*lot.edited_fields, *changed})
await s.flush()
if changed:
await audit.record(
s, ctx, "lot.update", "lot", lot.id, project_id=lot.project_id, before=before, after=_audit_lot(lot)
)
await s.refresh(lot)
return lot
async def _ensure_default(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> None:
"""一个包都不剩时建回默认包:项目回到「不分包」(PI2)。"""
if not (await s.execute(select(Lot.id).where(Lot.project_id == project_id).limit(1))).first():
s.add(
Lot(
id=uuid7(),
tenant_id=ctx.tenant_id,
project_id=project_id,
lot_no=DEFAULT_LOT_NO,
is_default=True,
origin="human",
edited_fields=[],
)
)
await s.flush()
async def delete_lot(s: AsyncSession, ctx: TenantContext, lot_id: UUID) -> None:
"""删包:原属这个包的清单项由外键改为各包通用(PI3)。"""
lot = await get_lot(s, lot_id)
await _editable(s, ctx, lot.project_id)
before = _audit_lot(lot)
await s.delete(lot)
await s.flush()
await _ensure_default(s, ctx, lot.project_id)
await audit.record(s, ctx, "lot.delete", "lot", lot.id, project_id=lot.project_id, before=before)
async def clear_lots(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> None:
"""「这个项目不分包」:删掉所有包,清单项都归为通用,建回默认包(PI2)。"""
p = await _editable(s, ctx, project_id)
existing = [lot for lot in await lots(s, p.id) if not lot.is_default]
if not existing:
return
await s.execute(delete(Lot).where(Lot.project_id == p.id))
await s.flush()
await _ensure_default(s, ctx, p.id)
await audit.record(
s, ctx, "lot.clear", "project", p.id, project_id=p.id, before={"lots": [_audit_lot(x) for x in existing]}
)
async def set_bidding(s: AsyncSession, ctx: TenantContext, project_id: UUID, lot_ids: list[UUID]) -> None:
p = await _editable(s, ctx, project_id)
real = {lot.id: lot for lot in await lots(s, p.id) if not lot.is_default}
chosen = set(lot_ids)
if not chosen or not chosen <= set(real):
raise _invalid("lot_ids", "至少选一个本项目的采购包")
before = sorted(lot.lot_no for lot in real.values() if lot.is_bidding)
await s.execute(
update(Lot).where(Lot.project_id == p.id, Lot.is_default.is_(False)).values(is_bidding=Lot.id.in_(chosen))
)
await s.flush()
after = sorted(real[i].lot_no for i in chosen)
await audit.record(
s, ctx, "project.bidding_lots", "project", p.id, project_id=p.id, before={"lots": before}, after={"lots": after}
)

View File

@@ -15,6 +15,7 @@ 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.identity import service as identity
from bid_doctor.project.models import Lot, Project, ProjectField, ProjectMember
UNNAMED = "未命名项目"
@@ -107,6 +108,7 @@ async def fields(s: AsyncSession, project_id: UUID) -> list[dict[str, Any]]:
"""项目基本信息字段(FR-M2-03):当前值、来源(AI / 人工)、AI 原值、出处、补遗带来的待确认值。"""
rows = (await s.execute(select(ProjectField).where(ProjectField.project_id == project_id))).scalars().all()
cites = await citations.to_api_many(s, [c for r in rows for c in (r.ai_citation_id, r.pending_citation_id) if c])
names = await identity.display_names(s, [r.human_set_by for r in rows if r.human_set_by])
out = []
for r in sorted(rows, key=lambda r: FIELD_ORDER.index(r.field_key)):
human = r.human_value is not None
@@ -117,7 +119,10 @@ async def fields(s: AsyncSession, project_id: UUID) -> list[dict[str, Any]]:
"source": "human" if human else "ai" if r.ai_value is not None else "none",
"ai_value": r.ai_value,
"citation": cites.get(r.ai_citation_id) if r.ai_citation_id else None,
"updated_by": None,
"updated_by": {"id": r.human_set_by, "name": names.get(r.human_set_by, "")}
if human and r.human_set_by
else None,
"updated_at": r.human_set_at if human else None,
"pending": {
"value": r.pending_value,
"citation": cites.get(r.pending_citation_id) if r.pending_citation_id else None,
@@ -143,6 +148,42 @@ async def bidding_lot_ids(s: AsyncSession, project_id: UUID) -> list[UUID]:
return list(rows.scalars())
async def basis(s: AsyncSession, project_id: UUID) -> dict[str, Any]:
"""查标用到的项目依据(PI5):投标截止时间、总价限价、法律体系、所投的包和各包限价,各带来源。
检查时存进覆盖范围;报告按它写,查标页拿它和当前的值比,改过就提示重新检查。"""
p = (await s.execute(select(Project).where(Project.id == project_id))).scalar_one()
rows = {
f.field_key: f
for f in (await s.execute(select(ProjectField).where(ProjectField.project_id == project_id))).scalars()
}
def field(key: str) -> dict[str, Any]:
f = rows.get(key)
human = f is not None and f.human_value is not None
return {"value": (f.human_value if human else f.ai_value) if f else None, "source": "human" if human else "ai"}
lots = (
await s.execute(
select(Lot)
.where(Lot.project_id == project_id, Lot.is_bidding, Lot.is_default.is_(False))
.order_by(Lot.sort_order)
)
).scalars()
return {
"deadline_at": {**field("deadline_at"), "value": p.deadline_at.isoformat() if p.deadline_at else None},
"ceiling_total": field("ceiling_total"),
"legal_regime": {**field("legal_regime"), "value": p.legal_regime},
"lots": [
{
"lot_no": x.lot_no,
"ceiling_amount": f"{x.ceiling_amount:.2f}" if x.ceiling_amount is not None else None,
"source": "human" if x.origin == "human" or "ceiling_amount" in x.edited_fields else "ai",
}
for x in lots
],
}
def mark_checked(p: Project, run_id: UUID, open_fatal: int) -> None:
"""完成过至少一次检查:跟进中 / 编制中 → 已检查(FR-M6-02);就绪标记随最近一次检查更新(data-model §5.6)。"""
if p.status in ("tracking", "drafting"):

View File

@@ -29,18 +29,29 @@ async def detail(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> dict[
docs = (await documents.summaries(s, [p.id]))[p.id]
pending = (await checklist.pending_counts(s, [p.id])).get(p.id, 0)
state = (await checklist.statuses(s, [p.id])).get(p.id)
fields = await projects.fields(s, p.id)
return {
**_row(p, lots, docs, pending, state),
"legal_regime": p.legal_regime,
"agency": p.agency,
"procurement_method": p.procurement_method,
"fields": await projects.fields(s, p.id),
"key_dates": await checklist.key_dates(s, p.id),
"fields": fields,
"key_dates": _key_dates(await checklist.key_dates(s, p.id), fields),
"created_by": {"id": p.created_by, "name": ctx.actor_name},
"created_at": p.created_at,
}
def _key_dates(dates: list[dict[str, Any]], fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""投标截止时间人工改过时,时间线上的「投标截止」用改后的值,标「已修改」(project-info-plan §2.1)。"""
f = next((x for x in fields if x["key"] == "deadline_at" and x["source"] == "human"), None)
if f is None:
return dates
rest = [d for d in dates if d.get("kind") != "bid_deadline"]
edited = {"item_id": None, "label": "投标截止", "kind": "bid_deadline", "at": f["value"], "edited": True}
return sorted([*rest, edited], key=lambda d: str(d["at"]))
def _row(
p: Project, lots: list[str], docs: dict[str, Any], pending: int, checklist_status: str | None
) -> dict[str, Any]:

View File

@@ -122,6 +122,11 @@ async def get_report(s: AsyncSession, report_id: UUID) -> Report:
return r
def _deadline(run: CheckRun) -> datetime | None:
at = ((run.coverage or {}).get("basis") or {}).get("deadline_at", {}).get("value")
return datetime.fromisoformat(at) if at else None
async def data(s: AsyncSession, ctx: TenantContext, job: ReportJob) -> dict[str, Any]:
"""报告要的数据:都从查标已有的接口函数取(检查批次、问题、需人工核对),和查标页看到的一致。"""
run = await checks.get_run(s, job.check_run_id)
@@ -146,7 +151,8 @@ async def data(s: AsyncSession, ctx: TenantContext, job: ReportJob) -> dict[str,
"purchaser": p.purchaser,
"legal_regime": run.legal_regime,
"procurement_method": p.procurement_method,
"deadline_at": p.deadline_at,
# 报告按检查时用的截止时间写(PI5),和问题明细一致;早于这项功能的检查没有记,用项目当前的值
"deadline_at": _deadline(run) or p.deadline_at,
"lots": [f"采购包 {lots[x]}" for x in run.lot_ids if lots.get(x)],
},
"run": {