feat(project): 项目基本信息修改和分包选择(FR-M2-03、04)
- 字段修改:人工值优先、撤销恢复 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:
@@ -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"])
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
293
server/src/bid_doctor/project/edit.py
Normal file
293
server/src/bid_doctor/project/edit.py
Normal 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}
|
||||
)
|
||||
@@ -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"):
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
160
server/tests/test_project_info.py
Normal file
160
server/tests/test_project_info.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""项目基本信息修改和分包(FR-M2-03、04,project-info-plan):人工值优先、撤销恢复 AI 值、校验、只读、留痕;
|
||||
分包增删改、默认包建回、「不分包」、删包时条目归通用、选包;清单按所投的包筛选;检查记下当时的项目依据(PI5)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from tests.conftest import User, project_with_tender, run_parse
|
||||
from tests.test_check_flow import run_check, upload_bid
|
||||
|
||||
|
||||
async def audit_actions(u: User) -> list[str]:
|
||||
from bid_doctor.audit.models import AuditEvent
|
||||
from bid_doctor.core.db import TenantContext, tenant_tx
|
||||
|
||||
ctx = TenantContext(tenant_id=UUID(u.tenant_id), user_id=UUID(int=0))
|
||||
async with tenant_tx(ctx) as s:
|
||||
return list((await s.execute(select(AuditEvent.action).order_by(AuditEvent.created_at))).scalars())
|
||||
|
||||
|
||||
def field(detail: dict[str, Any], key: str) -> dict[str, Any] | None:
|
||||
return next((f for f in detail["fields"] if f["key"] == key), None)
|
||||
|
||||
|
||||
async def test_fields_human_value_reset_and_validation(user: User) -> None:
|
||||
project_id, tender_id = await project_with_tender(user)
|
||||
assert (await run_parse(user, tender_id))["status"] == "succeeded"
|
||||
before = (await user.get(f"/projects/{project_id}")).json()
|
||||
|
||||
r = await user.put(f"/projects/{project_id}/fields/deadline_at", json={"value": "2026-12-01T09:30:00+08:00"})
|
||||
assert r.status_code == 200, r.text
|
||||
f = r.json()
|
||||
assert f["source"] == "human" and f["value"] == "2026-12-01T09:30:00+08:00" and f["updated_by"]["name"] == "张工"
|
||||
detail = (await user.get(f"/projects/{project_id}")).json()
|
||||
assert detail["deadline_at"].startswith("2026-12-01T01:30") # project 表上冗余的列跟着改
|
||||
(bid,) = [d for d in detail["key_dates"] if d["label"] == "投标截止"]
|
||||
assert bid["edited"] and bid["item_id"] is None and bid["at"] == "2026-12-01T09:30:00+08:00"
|
||||
|
||||
r = await user.put(f"/projects/{project_id}/fields/ceiling_total", json={"value": "286万"})
|
||||
assert r.json()["value"] == "2860000.00" # PI7:按元存
|
||||
r = await user.put(f"/projects/{project_id}/fields/accept_consortium", json={"value": False})
|
||||
assert r.json() == {**r.json(), "value": False, "source": "human"}
|
||||
r = await user.put(f"/projects/{project_id}/fields/name", json={"value": " 某市政务云扩容项目 "})
|
||||
assert (await user.get(f"/projects/{project_id}")).json()["name"] == "某市政务云扩容项目"
|
||||
|
||||
for key, value in (
|
||||
("name", " "),
|
||||
("ceiling_total", "-1"),
|
||||
("ceiling_total", "两百万"),
|
||||
("deadline_at", "明天"),
|
||||
("legal_regime", "civil"),
|
||||
("accept_imported", "是"),
|
||||
):
|
||||
r = await user.put(f"/projects/{project_id}/fields/{key}", json={"value": value})
|
||||
assert r.status_code == 422, (key, value, r.text)
|
||||
|
||||
# 撤销人工值:有 AI 值的恢复 AI 值,冗余的列也恢复;名称没有 AI 值时保持现在的名称
|
||||
await user.put(f"/projects/{project_id}/fields/legal_regime", json={"value": "gov_procurement"})
|
||||
assert (await user.get(f"/projects/{project_id}")).json()["legal_regime"] == "gov_procurement"
|
||||
r = await user.put(f"/projects/{project_id}/fields/legal_regime", json={"value": None})
|
||||
assert r.json()["source"] == "ai" and r.json()["value"] == "tendering"
|
||||
assert (await user.get(f"/projects/{project_id}")).json()["legal_regime"] == "tendering"
|
||||
r = await user.put(f"/projects/{project_id}/fields/name", json={"value": None})
|
||||
assert r.json()["source"] == "none"
|
||||
assert (await user.get(f"/projects/{project_id}")).json()["name"] == "某市政务云扩容项目"
|
||||
r = await user.put(f"/projects/{project_id}/fields/deadline_at", json={"value": None})
|
||||
after = (await user.get(f"/projects/{project_id}")).json()
|
||||
assert after["deadline_at"] == before["deadline_at"]
|
||||
assert not any(d.get("edited") for d in after["key_dates"])
|
||||
assert (await audit_actions(user)).count("project_field.set") == 8
|
||||
|
||||
# 已递交的项目只读
|
||||
assert (await user.post(f"/projects/{project_id}/transitions", json={"action": "submit"})).status_code == 200
|
||||
r = await user.put(f"/projects/{project_id}/fields/code", json={"value": "X-1"})
|
||||
assert r.status_code == 409 and r.json()["code"] == "project_read_only"
|
||||
|
||||
|
||||
async def test_lots_default_clear_and_checklist_scope(user: User) -> None:
|
||||
project_id, tender_id = await project_with_tender(user)
|
||||
await run_parse(user, tender_id)
|
||||
(default,) = (await user.get(f"/projects/{project_id}/lots")).json()["items"]
|
||||
assert default["is_default"]
|
||||
|
||||
r = await user.post(
|
||||
f"/projects/{project_id}/lots", json={"lot_no": "1", "name": "核心网络", "ceiling_amount": "120万"}
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
lot1 = r.json()
|
||||
assert lot1["ceiling_amount"] == "1200000.00" and lot1["origin"] == "human"
|
||||
lot2 = (await user.post(f"/projects/{project_id}/lots", json={"lot_no": "2", "name": "机房配套"})).json()
|
||||
lots = (await user.get(f"/projects/{project_id}/lots")).json()["items"]
|
||||
assert [x["lot_no"] for x in lots] == ["1", "2"] # 手工分包:默认包不要了
|
||||
assert (await user.post(f"/projects/{project_id}/lots", json={"lot_no": "1"})).status_code == 422
|
||||
assert (await user.patch(f"/lots/{lot2['id']}", json={"lot_no": "1"})).status_code == 422
|
||||
r = await user.patch(f"/lots/{lot2['id']}", json={"budget_amount": "80,000"})
|
||||
assert r.json()["budget_amount"] == "80000.00"
|
||||
|
||||
# 条目归属:包 2 的条目不在所投的包里时不列出
|
||||
created = []
|
||||
for title, lot in (("包1:交换机须支持硬件BFD", lot1["id"]), ("包2:精密空调制冷量不低于40kW", lot2["id"])):
|
||||
r = await user.post(
|
||||
f"/projects/{project_id}/checklist/items",
|
||||
json={"categories": ["tech_param"], "title": title, "lot_id": lot},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
created.append(r.json()["id"])
|
||||
everything = (await user.get(f"/projects/{project_id}/checklist/items", params={"lot_scope": "all"})).json()
|
||||
assert (await user.put(f"/projects/{project_id}/bidding-lots", json={"lot_ids": []})).status_code == 422
|
||||
assert (
|
||||
await user.put(f"/projects/{project_id}/bidding-lots", json={"lot_ids": [default["id"]]})
|
||||
).status_code == 422
|
||||
r = await user.put(f"/projects/{project_id}/bidding-lots", json={"lot_ids": [lot1["id"]]})
|
||||
assert [x["is_bidding"] for x in r.json()["items"]] == [True, False]
|
||||
shown = (await user.get(f"/projects/{project_id}/checklist/items")).json()
|
||||
assert shown["hidden_count"] == 1 and len(shown["items"]) == len(everything["items"]) - 1
|
||||
assert created[1] not in {i["id"] for i in shown["items"]}
|
||||
assert (await user.get(f"/projects/{project_id}")).json()["bidding_lots"] == ["1"]
|
||||
|
||||
# 删包:原属这个包的条目归为通用(PI3)
|
||||
assert (await user.delete(f"/lots/{lot2['id']}")).status_code == 204
|
||||
item = (await user.get(f"/checklist-items/{created[1]}")).json()
|
||||
assert item["lot_id"] is None
|
||||
# 删掉最后一个包:回到不分包(PI2),条目都列出
|
||||
assert (await user.delete(f"/lots/{lot1['id']}")).status_code == 204
|
||||
(back,) = (await user.get(f"/projects/{project_id}/lots")).json()["items"]
|
||||
assert back["is_default"]
|
||||
assert (await user.get(f"/projects/{project_id}/checklist/items")).json()["hidden_count"] == 0
|
||||
|
||||
# 一键「不分包」
|
||||
for no in ("A", "B", "C"):
|
||||
await user.post(f"/projects/{project_id}/lots", json={"lot_no": no})
|
||||
r = await user.post(f"/projects/{project_id}/lots/clear")
|
||||
assert [x["is_default"] for x in r.json()["items"]] == [True]
|
||||
actions = await audit_actions(user)
|
||||
assert {"lot.create", "lot.update", "lot.delete", "lot.clear", "project.bidding_lots"} <= set(actions)
|
||||
|
||||
|
||||
async def test_check_records_basis_and_flags_changes(user: User) -> None:
|
||||
project_id, tender_id = await project_with_tender(user)
|
||||
await run_parse(user, tender_id)
|
||||
await user.put(f"/projects/{project_id}/fields/deadline_at", json={"value": "2026-12-01T09:30:00+08:00"})
|
||||
bid = await upload_bid(user, project_id, "bid-mini-v1.pdf")
|
||||
run = await run_check(user, project_id, [bid["id"]])
|
||||
basis = run["coverage"]["basis"]
|
||||
assert basis["deadline_at"] == {"value": "2026-12-01T01:30:00+00:00", "source": "human"}
|
||||
assert basis["lots"] == [] and not run["basis_changed"]
|
||||
|
||||
await user.put(f"/projects/{project_id}/fields/deadline_at", json={"value": "2026-12-08T09:30:00+08:00"})
|
||||
assert (await user.get(f"/check-runs/{run['id']}")).json()["basis_changed"]
|
||||
|
||||
# 报告按检查时的截止时间写
|
||||
from bid_doctor.report.service import _deadline # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
class Run:
|
||||
coverage = run["coverage"]
|
||||
|
||||
assert _deadline(Run()).isoformat() == "2026-12-01T01:30:00+00:00" # type: ignore[arg-type]
|
||||
Reference in New Issue
Block a user