Files
shanxia2.0/docs/superpowers/plans/2026-06-23-pl-site-inventory-link.md
T
2026-06-23 23:30:57 +08:00

20 KiB
Raw Blame History

出入库单 · 工厂现场人员免登录链接 — 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_inventory commit 前 ensure
apply_product_snapshot_to_inventory(row, snapshot)
ensure_site_access_token(row)
db.add(row)
db.commit()
  • Step 3: create_link_for_plan commit 前 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。