20 KiB
出入库单 · 工厂现场人员免登录链接 — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 为每条 db_inbound_outbound 生成现场免登录链接,公共 Vant 页支持附件上传与备注;管理端可复制链接并回显现场回执。
Architecture: 复用司机公共页模式(token + /api/public/db/inventory 免 JWT/加密);创建出入库单时统一 _ensure_site_access_token;TransportInventoryLinkRead / arrange-info 返回 siteUrl;已完成单只读不可补传(S8)。
Tech Stack: FastAPI + SQLAlchemy + Pydantic v2 + MySQL 5.7;Vue 3 + Vant 4(public 路由)+ Element Plus(管理端)
Spec: docs/superpowers/specs/2026-06-23-pl-site-inventory-link-design.md
文件清单
| 操作 | 路径 | 职责 |
|---|---|---|
| Create | docs/sql/20260623_db_inventory_site_token.sql |
DDL:现场 token + 回执字段 |
| Modify | docs/sql/shanxia2.0.sql |
全量快照同步 |
| Modify | backend/app/models/db_models.py |
DbInboundOutbound 四列 |
| Create | backend/app/services/db_inventory_site_token.py |
token 生成 / URL 构建 |
| Create | backend/app/schemas/db/public_inventory.py |
公共页 GET/POST Schema |
| Modify | backend/app/schemas/db/inventory.py |
管理端读字段 + SiteUrlRead |
| Modify | backend/app/schemas/pl/transport.py |
TransportInventoryLinkRead 增 siteUrl |
| Create | backend/app/api/db/public_inventory.py |
现场免登录 API |
| Modify | backend/app/api/db/inventory.py |
创建路径 token + GET /{id}/site-url |
| Modify | backend/app/services/pl_dibang_bridge_service.py |
bridge 创建后 ensure token |
| Modify | backend/app/services/db/mappers.py |
inventory_to_read 映射现场回执 |
| Modify | backend/app/api/pl/transport.py |
arrange 结果带 siteUrl |
| Modify | backend/app/api/pl/plan.py |
arrange-info 带 siteUrl |
| Modify | backend/app/core/config.py |
加密白名单 |
| Modify | backend/app/core/attachment_biz.py |
db_inventory_site_feedback 目录 |
| Modify | backend/app/main.py |
注册 public inventory router |
| Create | backend/scripts/backfill_inventory_site_tokens.py |
历史单补 token |
| Create | web-admin/src/api/db/public-inventory.ts |
公共 API 类型与调用 |
| Create | web-admin/src/views/public/db/inventory/index.vue |
现场 Vant 页 |
| Modify | web-admin/src/router/routes/staticRoutes.ts |
公共路由 |
| Modify | web-admin/src/api/pl/index.ts |
TransportInventoryLink 增 siteUrl |
| Modify | web-admin/src/api/db/inventory.ts |
getSiteUrl + 读字段类型 |
| Modify | web-admin/src/components/business/pl-plan-arrange-dialog/index.vue |
复制现场链接 |
| Modify | web-admin/src/components/business/pl-plan-view-tabs/index.vue |
现场回执展示 + 复制 |
| Modify | web-admin/src/views/db/inventory/index.vue |
操作列复制现场链接 |
| Modify | docs/运输计划管理-需求说明文档.md |
修订 BR-33 / §8.3 |
Task 1: 数据库 DDL + ORM
Files:
-
Create:
docs/sql/20260623_db_inventory_site_token.sql -
Modify:
docs/sql/shanxia2.0.sql(db_inbound_outbound表结构段) -
Modify:
backend/app/models/db_models.py -
Step 1: 编写增量 SQL
-- docs/sql/20260623_db_inventory_site_token.sql
ALTER TABLE db_inbound_outbound
ADD COLUMN site_access_token VARCHAR(64) NULL COMMENT '现场人员免登录访问令牌' AFTER remark,
ADD COLUMN site_feedback_remark VARCHAR(500) NULL COMMENT '现场回执备注' AFTER site_access_token,
ADD COLUMN site_feedback_images TEXT NULL COMMENT '现场回执附件URLs(JSON数组)' AFTER site_feedback_remark,
ADD COLUMN site_feedback_time DATETIME NULL COMMENT '现场回执保存时间' AFTER site_feedback_images;
CREATE UNIQUE INDEX uk_db_inbound_outbound_site_token
ON db_inbound_outbound(site_access_token);
- Step 2: ORM 增加四列
在 DbInboundOutbound(backend/app/models/db_models.py)remark 后增加:
site_access_token = Column(String(64), nullable=True, comment="现场人员免登录访问令牌")
site_feedback_remark = Column(String(500), nullable=True, comment="现场回执备注")
site_feedback_images = Column(Text, nullable=True, comment="现场回执附件URLs(JSON数组)")
site_feedback_time = Column(DateTime, nullable=True, comment="现场回执保存时间")
- Step 3: 本地开发库执行 DDL
cd backend && source venv/bin/activate
mysql -h127.0.0.1 -uroot -p shanxia2.0 < ../docs/sql/20260623_db_inventory_site_token.sql
Expected: Query OK ×2,无 duplicate column 错误。
Task 2: Token 服务(公共函数)
Files:
-
Create:
backend/app/services/db_inventory_site_token.py -
Step 1: 实现 token 与 URL 工具
"""出入库单现场免登录 token"""
from __future__ import annotations
import secrets
from app.core.config import settings
from app.models.db_models import DbInboundOutbound
def ensure_site_access_token(row: DbInboundOutbound) -> str:
token = (row.site_access_token or "").strip()
if not token:
token = secrets.token_urlsafe(32)[:64]
row.site_access_token = token
return token
def build_site_inventory_url(token: str) -> str:
origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/")
return f"{origin}/#/public/db/inventory/{token}"
- Step 2: 验证 import
cd backend && source venv/bin/activate
python -c "from app.services.db_inventory_site_token import ensure_site_access_token, build_site_inventory_url; print('ok')"
Expected: ok
Task 3: 所有创建路径注入 token
Files:
-
Modify:
backend/app/services/pl_dibang_bridge_service.py(_create_db_inventory) -
Modify:
backend/app/api/db/inventory.py(create_inventory、create_link_for_plan) -
Step 1: bridge 创建后 ensure
在 _create_db_inventory 的 db.add(inventory); db.flush() 之后:
from app.services.db_inventory_site_token import ensure_site_access_token
ensure_site_access_token(inventory)
- Step 2:
create_inventorycommit 前 ensure
apply_product_snapshot_to_inventory(row, snapshot)
ensure_site_access_token(row)
db.add(row)
db.commit()
- Step 3:
create_link_for_plancommit 前 ensure
同上,在 db.add(row) 前调用 ensure_site_access_token(row)。
- Step 4: 手工验证
地磅模块新建一条出入库单 → 查库 site_access_token IS NOT NULL。
SELECT id, batch_no, site_access_token FROM db_inbound_outbound ORDER BY create_time DESC LIMIT 1;
Task 4: 公共 API Schema
Files:
-
Create:
backend/app/schemas/db/public_inventory.py -
Step 1: 定义 Pydantic 模型
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class PublicInventoryFeedback(BaseModel):
site_feedback_remark: Optional[str] = Field(default=None, alias="siteFeedbackRemark")
site_feedback_images: Optional[list[str]] = Field(default=None, alias="siteFeedbackImages")
model_config = {"populate_by_name": True}
class PublicInventoryPageRead(BaseModel):
batch_no: Optional[str] = Field(default=None, alias="batchNo")
business_type: str = Field(alias="businessType")
date: Optional[str] = None
partner_company: str = Field(alias="partnerCompany")
product: str
specification: Optional[str] = None
quantity: float
vehicle_no: str = Field(alias="vehicleNo")
plan_no: Optional[str] = Field(default=None, alias="planNo")
status: Optional[str] = None
remark: Optional[str] = None
site_feedback_remark: Optional[str] = Field(default=None, alias="siteFeedbackRemark")
site_feedback_images: list[str] = Field(default_factory=list, alias="siteFeedbackImages")
site_feedback_time: Optional[datetime] = Field(default=None, alias="siteFeedbackTime")
read_only: bool = Field(default=False, alias="readOnly")
voided: bool = False
model_config = {"populate_by_name": True}
class InventorySiteUrlRead(BaseModel):
site_url: str = Field(alias="siteUrl")
model_config = {"populate_by_name": True}
Task 5: 现场公共 API
Files:
-
Create:
backend/app/api/db/public_inventory.py -
Modify:
backend/app/main.py -
Modify:
backend/app/core/config.py -
Modify:
backend/app/core/attachment_biz.py -
Step 1: 实现 router(对齐
public_transport.py)
核心逻辑:
COMPLETED_STATUS = "已完成"
def _get_inventory_by_token(db: Session, token: str) -> DbInboundOutbound:
row = db.query(DbInboundOutbound).filter(
DbInboundOutbound.site_access_token == token,
DbInboundOutbound.del_flag == 0,
).first()
if not row:
raise HTTPException(status_code=404, detail="链接无效或已失效")
return row
def _resolve_plan_voided(db: Session, inventory: DbInboundOutbound) -> bool:
link = (
db.query(PlTransportDbLink)
.filter(
PlTransportDbLink.db_inventory_id == inventory.id,
PlTransportDbLink.del_flag == 0,
)
.order_by(PlTransportDbLink.create_time.desc())
.first()
)
if not link:
return False
plan = db.query(PlPlan).filter(PlPlan.id == link.plan_id, PlPlan.del_flag == 0).first()
return bool(plan and plan.follow_status == "cancelled")
def _assert_editable(inventory: DbInboundOutbound, voided: bool) -> None:
if voided:
raise HTTPException(status_code=400, detail="运输计划已作废,无法提交回执")
if (inventory.status or "").strip() == COMPLETED_STATUS:
raise HTTPException(status_code=400, detail="单据已完成,无法修改回执")
-
GET /{token}:构建PublicInventoryPageRead;readOnly = status==已完成;voided来自_resolve_plan_voided -
POST /{token}/feedback:先_assert_editable;写site_feedback_*+site_feedback_time=now -
POST /{token}/attachments:先_assert_editable;不校验 MIME(S13);biz_type=db_inventory_site_feedback -
Step 2: attachment_biz 增加目录映射
"db_inventory_site_feedback": "db/inventory_site",
"pl_transport_feedback": "pl/transport_feedback", # 若司机页尚未登记可一并补上
- Step 3: 注册路由
backend/app/main.py:
from app.api.db.public_inventory import router as public_db_inventory_router
app.include_router(public_db_inventory_router, prefix=settings.API_V1_PREFIX)
- Step 4: 加密白名单
backend/app/core/config.py 的 API_ENCRYPT_EXCLUDE_PATHS 末尾追加:
,/api/public/db/inventory
- Step 5: curl 冒烟
# 先从库取一条 token
TOKEN="<site_access_token>"
curl -s "http://127.0.0.1:8000/api/public/db/inventory/${TOKEN}" | head -c 200
Expected: JSON code:200,含 batchNo、readOnly。
Task 6: 管理端 Schema + mapper + site-url API
Files:
-
Modify:
backend/app/schemas/db/inventory.py -
Modify:
backend/app/services/db/mappers.py -
Modify:
backend/app/api/db/inventory.py -
Step 1:
InboundOutboundRead增加(不暴露 token)
site_feedback_remark: Optional[str] = Field(default=None, alias="siteFeedbackRemark")
site_feedback_images: Optional[list[str]] = Field(default=None, alias="siteFeedbackImages")
site_feedback_time: Optional[str] = Field(default=None, alias="siteFeedbackTime")
- Step 2:
inventory_to_read解析 JSON images
import json
def _parse_site_feedback_images(raw: str | None) -> list[str] | None:
if not raw:
return None
try:
parsed = json.loads(raw)
if isinstance(parsed, list):
return [str(x) for x in parsed if x]
except (TypeError, json.JSONDecodeError):
pass
return [p.strip() for p in raw.split(",") if p.strip()] or None
映射到 Read 的 siteFeedbackRemark / siteFeedbackImages / siteFeedbackTime。
- Step 3: 管理端
GET /db/inventory/{id}/site-url
@router.get("/{id}/site-url", response_model=InventorySiteUrlRead)
def get_inventory_site_url(id: str, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user)):
tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
row = _get_inventory(db, tenant_no, id)
token = ensure_site_access_token(row) # lazy 补历史单
db.commit()
return InventorySiteUrlRead(site_url=build_site_inventory_url(token))
Task 7: 运输联动响应扩展 siteUrl
Files:
-
Modify:
backend/app/schemas/pl/transport.py -
Modify:
backend/app/api/pl/transport.py -
Modify:
backend/app/api/pl/plan.py -
Modify:
backend/app/services/pl_dibang_bridge_service.py(link 结果若需带 token) -
Step 1: Schema 扩展
TransportInventoryLinkRead 增加:
site_access_token: Optional[str] = Field(default=None, alias="siteAccessToken")
site_url: Optional[str] = Field(default=None, alias="siteUrl")
- Step 2:
_build_arrange_result/ bridge link 填充
构建 TransportInventoryLinkRead 时查询 inventory 行:
token = (inventory.site_access_token or "").strip() if inventory else ""
site_url = build_site_inventory_url(token) if token else None
- Step 3:
get_plan_arrange_info同步填充 siteUrl
与 Step 2 相同逻辑,遍历 inventory_records。
Task 8: 前端公共页(Vant)
Files:
-
Create:
web-admin/src/api/db/public-inventory.ts -
Create:
web-admin/src/views/public/db/inventory/index.vue -
Modify:
web-admin/src/router/routes/staticRoutes.ts -
Step 1: API 模块(复用
public-pl.ts的 axios 实例)
import { publicPlGet, publicPlPost, publicPlUpload } from '@/utils/http/public-pl'
export interface PublicInventoryPage {
batchNo?: string
businessType: string
date?: string
partnerCompany: string
product: string
specification?: string
quantity: number
vehicleNo: string
planNo?: string
status?: string
remark?: string
siteFeedbackRemark?: string
siteFeedbackImages: string[]
siteFeedbackTime?: string
readOnly: boolean
voided: boolean
}
export const publicInventoryApi = {
getPage: (token: string) =>
publicPlGet<PublicInventoryPage>(`/public/db/inventory/${token}`),
saveFeedback: (token: string, data: { siteFeedbackRemark?: string; siteFeedbackImages?: string[] }) =>
publicPlPost<{ success: boolean }>(`/public/db/inventory/${token}/feedback`, data),
uploadAttachment: (token: string, file: File) =>
publicPlUpload<{ fileUrl: string }>(`/public/db/inventory/${token}/attachments`, file)
}
- Step 2: 页面实现(复制
public/pl/transport/index.vue结构)
要点:
-
van-empty v-if="page?.voided"→description="运输计划已作废" -
van-empty v-else-if="page?.readOnly"时不展示 uploader/保存按钮,仅展示历史回执 -
van-uploader不设accept属性(S13) -
max-count="9";before-delete允许删除(S10) -
标题:
出入库单;摘要展示batchNo+businessType -
Step 3: 注册路由(放在 404 catch-all 之前)
{
path: '/public/db/inventory/:token',
name: 'PublicDbInventorySite',
component: () => import('@views/public/db/inventory/index.vue'),
meta: { title: '出入库单', isHideTab: true, isPublicMobile: true }
}
- Step 4: 浏览器验收 V1/V3
安排运输勾地磅 → 复制 siteUrl → 手机模式打开 → 上传任意类型文件 → 保存 → 刷新仍可见。
Task 9: 安排运输成功态 — 复制现场链接(P2)
Files:
-
Modify:
web-admin/src/api/pl/index.ts -
Modify:
web-admin/src/components/business/pl-plan-arrange-dialog/index.vue -
Step 1: TS 类型
TransportInventoryLink 增加 siteUrl?: string。
- Step 2: 成功态 footer 按钮(S11)
function buildAllSiteLinksText(records: TransportInventoryLink[]) {
return records
.filter(r => r.siteUrl)
.map(r => `${r.productName || r.batchNo}:${r.siteUrl}`)
.join('\n')
}
async function copySiteUrl(url?: string) {
if (!url) return
await navigator.clipboard.writeText(url)
ElMessage.success('已复制现场链接')
}
async function copyAllSiteUrls() {
const text = buildAllSiteLinksText(inventoryRecords.value)
if (!text) return
await navigator.clipboard.writeText(text)
ElMessage.success('已复制全部现场链接')
}
UI:
- 1 条:
复制现场链接 - 多条:
复制全部现场链接+ 成功区块内每条复制本条({productName})
Task 10: 计划查看地磅 Tab 回显(P2)
Files:
-
Modify:
web-admin/src/components/business/pl-plan-view-tabs/index.vue -
Modify:
web-admin/src/api/db/inventory.ts(如需getSiteUrl) -
Step 1: 地磅卡片增加现场回执区
在现有出入库 KV 下方增加(有值才展示):
-
现场备注:
siteFeedbackRemark -
附件:链接列表或缩略图(复用 MinIO base URL 拼接)
-
上次保存:
siteFeedbackTime -
按钮「复制现场链接」→
inventoryApi.getSiteUrl(dbInventoryId) -
Step 2: 确保 Tab 加载 inventory 详情含新字段
若当前仅读 link 快照,改为 inventoryApi.get(id) 或扩展 plan 详情接口返回现场字段。
Task 11: 地磅出入库列表复制链接(P3)
Files:
-
Modify:
web-admin/src/views/db/inventory/index.vue -
Modify:
web-admin/src/api/db/inventory.ts -
Step 1: API
getSiteUrl: (id: string) => request.get<{ siteUrl: string }>(`/db/inventory/${id}/site-url`)
- Step 2: 操作列增加 link 按钮
<el-button link type="success" @click="handleCopySiteUrl(row)">
<el-icon><Link /></el-icon>复制现场链接
</el-button>
已完成单仍可复制(公共页只读,S8/S9)。
Task 12: 历史数据补 token(P3)
Files:
-
Create:
backend/scripts/backfill_inventory_site_tokens.py -
Step 1: 脚本逻辑
"""为 site_access_token 为空的出入库单补 token"""
# 查询 del_flag=0 AND site_access_token IS NULL
# 循环 ensure_site_access_token + commit
- Step 2: 执行(可选,部署后一次)
cd backend && source venv/bin/activate
python scripts/backfill_inventory_site_tokens.py
Task 13: 需求文档修订 BR-33(P3)
Files:
-
Modify:
docs/运输计划管理-需求说明文档.md -
Step 1: 更新 §8.3 与 BR-33
| 项目 | 修订后 |
|---|---|
| 现场人员 | 免登录 token 链接(与司机一致) |
| 链接路径 | /#/public/db/inventory/{token} |
| 已完成单 | 链接可打开查看,不可补传附件 |
Task 14: 联调验收 + 图谱更新
- Step 1: 验收清单(对照 spec §八)
| # | 操作 | 预期 |
|---|---|---|
| V1 | 安排+地磅 1 产品 | siteUrl 可打开、可上传保存 |
| V2 | 2 产品 | 2 条独立 siteUrl |
| V4 | 调整运输重建 | 旧 token 404 |
| V5 | 出入库单改已完成 | 公共页 readOnly,POST 400 |
| V6 | 作废计划 | voided 空态,POST 400 |
| V9 | 手工新建出入库 | 有 token,列表可复制 |
| V10 | 删附件后保存 | 回显不含已删项 |
- Step 2: 更新知识图谱
./scripts/update-graph.sh all
- Step 3: 前端 typecheck(可选)
cd web-admin && pnpm build:typecheck
Spec 覆盖自检
| Spec 决策 | Task |
|---|---|
| S1 免登录 token | Task 5 |
| S2 每单一条链接 | Task 2–3, 7 |
| S3 仅摘要+附件+备注 | Task 5, 8 |
| S4 覆盖式保存 | Task 5 POST feedback |
| S5 全路径创建生成 | Task 3 |
| S6 调整运输旧链 404 | 既有 revoke + 新单新 token(Task 7) |
| S7 BR-33 | Task 13 |
| S8 已完成不可补传 | Task 5 _assert_editable |
| S9 手工单也有链接 | Task 3, 11 |
| S10 可删附件 | Task 8 uploader |
| S11 复制全部+逐条 | Task 9 |
| S12 作废空态 | Task 5, 8 |
| S13 不限附件类型 | Task 5, 8 无 accept |
执行顺序建议
Task 1 → 2 → 3 → 4 → 5 → 6 → 7 (P1 后端闭环)
→ 8 (P1 公共页)
→ 9 → 10 (P2 管理端)
→ 11 → 12 → 13 → 14 (P3 收尾)
依赖: Task 8 依赖 Task 5;Task 9 依赖 Task 7;Task 10/11 依赖 Task 6。