Add ensure-copy-seq API and miniprogram clipboard prefix so each plan gets a tenant-scoped fixed serial on first copy; cover allocation with unit tests and refresh the backend graph. Co-authored-by: Cursor <cursoragent@cursor.com>
17 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: 为 pl_plan 增加租户内固定流水 copy_seq_no:历史按创建时间回填;新计划首次「复制发群」时发号;小程序复制文案在「计划编号」上方输出 序号:n。
Architecture: 落库字段 + 专用 POST .../ensure-copy-seq(行锁 + 租户 MAX+1,含软删)。小程序复制前先 ensure,失败则中止复制。不在创建计划路径发号,不在列表/详情 UI 展示。
Tech Stack: MySQL 5.7、FastAPI、SQLAlchemy、uni-app Vue 3、pytest(对齐 allocate_reimburse_title_seq / test_bx_reimburse_title_seq.py)
Spec: docs/superpowers/specs/2026-10-09-pl-plan-copy-seq-design.md
File map
| 文件 | 职责 |
|---|---|
docs/sql/migrate_pl_plan_copy_seq_no_20261009.sql |
ALTER + 按租户回填 |
docs/sql/shanxia2.0.sql |
基线 pl_plan 定义同步 copy_seq_no(若仓库要求基线一致) |
backend/app/models/pl_models.py |
PlPlan.copy_seq_no |
backend/app/services/pl_driver_plan_service.py |
allocate_or_get_plan_copy_seq + ensure_copy_seq_for_driver |
backend/app/api/pl/driver_plan.py |
POST /{plan_id}/ensure-copy-seq |
backend/tests/test_pl_plan_copy_seq.py |
SQLite 发号 / 幂等 / 含软删 MAX |
miniprogram/api/pl.js |
driverPlanApi.ensureCopySeq |
miniprogram/utils/plDriverPlanCopy.js |
文案首行 序号: |
miniprogram/pages/pl/driver-plan/list.vue |
handleCopy:get → ensure → copy |
miniprogram/pages/pl/driver-plan/detail.vue |
保存成功复制:ensure → copy |
Task 1: Schema 迁移 + ORM
Files:
-
Create:
docs/sql/migrate_pl_plan_copy_seq_no_20261009.sql -
Modify:
backend/app/models/pl_models.py(PlPlan公共字段前) -
Modify:
docs/sql/shanxia2.0.sql(pl_planCREATE 中增加同列;搜索CREATE TABLE `pl_plan`) -
Step 1: 写迁移 SQL
-- 运输计划复制发群固定序号(租户内流水)
-- MySQL 5.7
-- 2026-10-09
-- 若列已存在,跳过 ALTER(重复执行会报 Duplicate column)。
ALTER TABLE `pl_plan`
ADD COLUMN `copy_seq_no` int(11) DEFAULT NULL COMMENT '复制发群序号(租户内固定流水,首次复制时分配)' AFTER `plan_no`,
ADD KEY `idx_pl_plan_tenant_copy_seq` (`tenant_no`, `copy_seq_no`);
-- 历史回填:按租户 create_time ASC, id ASC,含软删;已有值不覆盖
-- MySQL 5.7 用户变量按租户编号(会话内执行)
SET @prev_tenant := NULL;
SET @rn := 0;
UPDATE `pl_plan` p
INNER JOIN (
SELECT
t.id,
t.seq_no
FROM (
SELECT
id,
tenant_no,
@rn := IF(@prev_tenant = tenant_no, @rn + 1, 1) AS seq_no,
@prev_tenant := tenant_no AS _tenant
FROM (
SELECT id, tenant_no, create_time
FROM `pl_plan`
ORDER BY tenant_no ASC, create_time ASC, id ASC
) ordered
) t
) ranked ON ranked.id = p.id
SET p.`copy_seq_no` = ranked.seq_no
WHERE p.`copy_seq_no` IS NULL;
在目标库手动执行一次(或按运维流程执行)。重复执行:第二次 ADD COLUMN 会失败,属预期;回填 WHERE IS NULL 可单独重跑(需先去掉 ADD,或整文件只跑一次)。
- Step 2: ORM 增加字段
在 PlPlan 中 plan_no 后增加:
copy_seq_no = Column(Integer, nullable=True, comment="复制发群序号(租户内固定流水)")
(文件顶部已有 Integer import 则复用。)
- Step 3: 同步
docs/sql/shanxia2.0.sql中pl_plan表结构
在 `plan_no` varchar(32) ... 下一行增加:
`copy_seq_no` int(11) DEFAULT NULL COMMENT '复制发群序号(租户内固定流水,首次复制时分配)',
并在 KEY 区增加 KEY `idx_pl_plan_tenant_copy_seq` (`tenant_no`, `copy_seq_no`)(若该 dump 含索引段)。
- Step 4: Commit
git add docs/sql/migrate_pl_plan_copy_seq_no_20261009.sql docs/sql/shanxia2.0.sql backend/app/models/pl_models.py
git commit -m "$(cat <<'EOF'
feat(pl): add pl_plan.copy_seq_no for copy-to-group sequence
Persist a tenant-scoped fixed serial for driver-plan clipboard text;
include migration backfill by create_time.
EOF
)"
Task 2: Service 发号逻辑 + 单测(TDD)
Files:
-
Modify:
backend/app/services/pl_driver_plan_service.py -
Create:
backend/tests/test_pl_plan_copy_seq.py -
Step 1: 写失败测试
创建 backend/tests/test_pl_plan_copy_seq.py:
"""pl_plan.copy_seq_no:租户内 MAX+1,含软删;已有号幂等返回。"""
from datetime import date, datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models.pl_models import PlPlan
from app.services.pl_driver_plan_service import allocate_or_get_plan_copy_seq
TENANT = "t-copy-seq"
def _session():
engine = create_engine("sqlite://")
PlPlan.__table__.create(engine)
return sessionmaker(bind=engine)()
def _plan(
*,
plan_id: str,
plan_no: str,
create_time: datetime,
copy_seq_no=None,
del_flag: int = 0,
tenant_no: str = TENANT,
) -> PlPlan:
return PlPlan(
id=plan_id,
plan_no=plan_no,
copy_seq_no=copy_seq_no,
create_date=create_time.date() if hasattr(create_time, "date") else date(2026, 1, 1),
belongs_company="shanxia",
plan_type="direct",
follow_status="pending_arrange",
transport_status="pending",
archive_status="unarchived",
complete_status="no",
has_freight=0,
total_amount=0,
freight_amount=0,
hazard_cert_ready=0,
erp_entered=0,
tenant_no=tenant_no,
create_dept_id="d1",
create_time=create_time,
update_time=create_time,
del_flag=del_flag,
)
def test_allocate_first_seq_when_tenant_empty():
db = _session()
db.add(_plan(plan_id="p1", plan_no="PO1", create_time=datetime(2026, 10, 1, 10, 0, 0)))
db.commit()
assert allocate_or_get_plan_copy_seq(db, tenant_no=TENANT, plan_id="p1") == 1
db.commit()
assert db.query(PlPlan).get("p1").copy_seq_no == 1
def test_allocate_skips_soft_deleted_max():
"""软删占用的号计入 MAX,不回收。"""
db = _session()
db.add(
_plan(
plan_id="old",
plan_no="PO0",
create_time=datetime(2026, 9, 1, 10, 0, 0),
copy_seq_no=3,
del_flag=1,
)
)
db.add(
_plan(
plan_id="new",
plan_no="PO1",
create_time=datetime(2026, 10, 1, 10, 0, 0),
copy_seq_no=None,
)
)
db.commit()
assert allocate_or_get_plan_copy_seq(db, tenant_no=TENANT, plan_id="new") == 4
def test_allocate_idempotent_when_already_set():
db = _session()
db.add(
_plan(
plan_id="p1",
plan_no="PO1",
create_time=datetime(2026, 10, 1, 10, 0, 0),
copy_seq_no=7,
)
)
db.commit()
assert allocate_or_get_plan_copy_seq(db, tenant_no=TENANT, plan_id="p1") == 7
assert db.query(PlPlan).get("p1").copy_seq_no == 7
def test_allocate_missing_plan_returns_none():
db = _session()
assert allocate_or_get_plan_copy_seq(db, tenant_no=TENANT, plan_id="missing") is None
- Step 2: 跑测试确认失败
cd backend && source venv/bin/activate && pytest tests/test_pl_plan_copy_seq.py -v
Expected: FAIL(allocate_or_get_plan_copy_seq 未定义或 ImportError)
- Step 3: 实现发号函数
在 pl_driver_plan_service.py 顶部确保有:
from sqlalchemy import and_, func, or_
(已有 func 则不重复。)增加:
def allocate_or_get_plan_copy_seq(
db: Session, *, tenant_no: str, plan_id: str
) -> Optional[int]:
"""
返回计划的 copy_seq_no;若为空则按租户 MAX(含软删)+1 写入后返回。
调用方负责 commit。计划不存在返回 None。
"""
plan = (
db.query(PlPlan)
.filter(PlPlan.id == plan_id, PlPlan.tenant_no == tenant_no)
.with_for_update()
.first()
)
if not plan:
return None
if plan.copy_seq_no is not None:
return int(plan.copy_seq_no)
max_seq = (
db.query(func.max(PlPlan.copy_seq_no))
.filter(PlPlan.tenant_no == tenant_no)
.with_for_update()
.scalar()
)
next_seq = int(max_seq or 0) + 1
plan.copy_seq_no = next_seq
db.flush()
return next_seq
def ensure_copy_seq_for_driver(
db: Session, *, tenant_no: str, plan_id: str, personnel_id: str
) -> Tuple[Optional[int], Optional[str]]:
"""
司机可见性与 GET 详情一致;通过后分配/返回 copy_seq_no。
成功返回 (seq, None);失败返回 (None, err)。
"""
plan, transport = resolve_transport_for_driver_plan(
db, tenant_no=tenant_no, plan_id=plan_id, personnel_id=personnel_id
)
if not plan:
return None, "计划不存在"
if not transport:
return None, "无权限查看该运输计划或未安排运输"
seq = allocate_or_get_plan_copy_seq(db, tenant_no=tenant_no, plan_id=plan.id)
if seq is None:
return None, "计划不存在"
return seq, None
注意:allocate_or_get 不要过滤 del_flag=0(回填含软删;MAX 含软删)。可见性仍由 resolve_transport_for_driver_plan 限制(正常司机只能复制未删计划)。
- Step 4: 跑测试确认通过
cd backend && source venv/bin/activate && pytest tests/test_pl_plan_copy_seq.py -v
Expected: PASS(若 SQLite 对 with_for_update 报错,可在 allocate 内对 SQLite dialect 跳过 with_for_update,或改用 .with_for_update(read=True);优先保持与 bx_codegen.allocate_reimburse_title_seq 一致,该项目 SQLite 已能跑通。)
- Step 5: Commit
git add backend/app/services/pl_driver_plan_service.py backend/tests/test_pl_plan_copy_seq.py
git commit -m "$(cat <<'EOF'
feat(pl): allocate copy_seq_no on first ensure for driver plan
Add tenant MAX+1 allocation with row lock; cover soft-delete and
idempotent reuse in unit tests.
EOF
)"
Task 3: API 路由
Files:
-
Modify:
backend/app/api/pl/driver_plan.py -
Step 1: 增加路由
在 get_driver_plan_detail 之后、feedback 之前插入:
@router.post("/{plan_id}/ensure-copy-seq")
def ensure_driver_plan_copy_seq(
plan_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())
personnel = _resolve_personnel_or_empty(db, user, tenant_no)
if not personnel:
raise HTTPException(status_code=404, detail="未关联驾押人员,无法查看")
seq, err = drv_svc.ensure_copy_seq_for_driver(
db,
tenant_no=tenant_no,
plan_id=plan_id,
personnel_id=personnel.id,
)
if err or seq is None:
raise HTTPException(status_code=404, detail=err or "计划不存在")
db.commit()
return success_response({"copySeqNo": seq})
- Step 2: 冒烟(可选,有本地库时)
用已绑定驾押人员的 token 调用:
# 示例:替换 TOKEN / PLAN_ID
curl -s -X POST "http://127.0.0.1:8000/api/transport/driver-plan/{PLAN_ID}/ensure-copy-seq" \
-H "Authorization: Bearer {TOKEN}"
Expected JSON:code 成功且 data.copySeqNo 为整数;再调一次号不变。
- Step 3: Commit
git add backend/app/api/pl/driver_plan.py
git commit -m "$(cat <<'EOF'
feat(pl): expose POST driver-plan ensure-copy-seq
Drivers can allocate or fetch the fixed copy-to-group serial before
clipboard copy.
EOF
)"
Task 4: 小程序 API + 复制文案
Files:
-
Modify:
miniprogram/api/pl.js -
Modify:
miniprogram/utils/plDriverPlanCopy.js -
Step 1: API 方法
在 driverPlanApi 中 get 旁增加:
ensureCopySeq: async (planId) =>
unwrapData(await db.post(`/transport/driver-plan/${planId}/ensure-copy-seq`, {})),
- Step 2: 文案首行
改 buildDriverPlanCopyText,在 lines 最前插入序号(支持 copySeqNo / copy_seq_no):
export function buildDriverPlanCopyText(page = {}) {
const stops = Array.isArray(page?.stops) ? page.stops : []
const loadStop = stops.find((s) => stopTypeOf(s) === 'load')
const deliveryStops = stops.filter((s) => stopTypeOf(s) === 'delivery')
const rawSeq = page?.copySeqNo ?? page?.copy_seq_no
const seqText =
rawSeq != null && rawSeq !== '' ? String(rawSeq) : DASH
const lines = [
`序号:${seqText}`,
`计划编号:${String(page?.planNo || page?.plan_no || '').trim() || DASH}`,
`装货方:${loadStop ? customerNameOf(loadStop) : DASH}`
]
// ... 其余逻辑保持不变
- Step 3: Commit
git add miniprogram/api/pl.js miniprogram/utils/plDriverPlanCopy.js
git commit -m "$(cat <<'EOF'
feat(mini): include copySeqNo in driver plan clipboard text
Add ensureCopySeq API helper and prefix 序号 line above plan number.
EOF
)"
Task 5: 列表 / 详情复制交互
Files:
-
Modify:
miniprogram/pages/pl/driver-plan/list.vue -
Modify:
miniprogram/pages/pl/driver-plan/detail.vue -
Step 1: 列表
handleCopy
替换为(ensure 失败则中止,不写剪贴板):
async function handleCopy(row) {
const id = row?.planId || row?.plan_id
if (!id) {
uni.showToast({ title: '计划无效', icon: 'none' })
return
}
uni.showLoading({ title: '加载中...', mask: true })
try {
const page = (await driverPlanApi.get(id)) || {}
const ensured = await driverPlanApi.ensureCopySeq(id)
const copySeqNo = ensured?.copySeqNo ?? ensured?.copy_seq_no
if (copySeqNo == null || copySeqNo === '') {
throw new Error('no seq')
}
uni.hideLoading()
await copyDriverPlanToClipboard({ ...page, copySeqNo })
} catch (e) {
uni.hideLoading()
uni.showToast({ title: '复制失败', icon: 'none' })
}
}
- Step 2: 详情保存成功复制
在 promptCopyAfterSave 的 res.confirm 分支中,先 ensure 再复制:
if (res.confirm) {
try {
const ensured = await driverPlanApi.ensureCopySeq(planId.value)
const copySeqNo = ensured?.copySeqNo ?? ensured?.copy_seq_no
if (copySeqNo == null || copySeqNo === '') {
uni.showToast({ title: '复制失败', icon: 'none' })
resolve()
return
}
const ok = await copyDriverPlanToClipboard({
...(page.value || {}),
copySeqNo
})
if (ok) {
setTimeout(() => goList(), 400)
}
} catch (e) {
uni.showToast({ title: '复制失败', icon: 'none' })
}
}
确认 detail.vue 已 import { driverPlanApi } from '@/api/pl'(或现有等价导入)。
- Step 3: 手工验收清单
- 执行迁移后,任选两条历史计划首次复制:序号应符合创建时间先后,且再次复制不变。
- 新建计划(不复制):库中
copy_seq_no为NULL。 - 对该新计划点「复制发群」:文案首行
序号:{MAX+1},库中已写入。 - 列表与回执弹窗两条路径均成功;UI 上无序号展示。
- ensure 失败(断网/无权限)toast「复制失败」,剪贴板不更新为半成品(或保持原内容)。
- Step 4: Commit
git add miniprogram/pages/pl/driver-plan/list.vue miniprogram/pages/pl/driver-plan/detail.vue
git commit -m "$(cat <<'EOF'
feat(mini): ensure copy_seq before driver plan share-to-group
Call ensure-copy-seq on list and post-save copy; abort clipboard on
failure so drivers always get a fixed serial when copy succeeds.
EOF
)"
Task 6: 图谱更新 + 设计状态
Files:
-
Modify:
docs/superpowers/specs/2026-10-09-pl-plan-copy-seq-design.md(状态改为「已确认」) -
Run:
./scripts/update-graph.sh backend(若脚本支持)或仓库约定的 graphify 更新 -
Step 1: 将设计文档头部
状态: 待用户确认改为已确认。 -
Step 2: 代码改动后更新 backend 图谱:
./scripts/update-graph.sh backend
若脚本不存在,则在仓库根目录:graphify update backend(以 AGENTS.md / graphify.mdc 为准)。
- Step 3: Commit
git add docs/superpowers/specs/2026-10-09-pl-plan-copy-seq-design.md
# 若有 graphify 产出一并 add
git commit -m "$(cat <<'EOF'
docs(pl): mark copy-seq design confirmed and refresh graph
EOF
)"
Spec coverage checklist
| Spec 项 | Task |
|---|---|
| D1 租户累加 | Task 2 MAX+1 |
| D2 不回收 / 含软删 | Task 1 回填 + Task 2 测试 |
| D3 仅复制文案 | Task 4–5(无 UI) |
| D4 首次复制发号 | Task 3–5 |
| D5 历史回填 | Task 1 SQL |
| D6 / D7 文案格式 | Task 4 |
| D8 不用 GET 发号 | Task 3 专用 POST |
| ensure 失败中止复制 | Task 5 |
| 加密排除 | 无需改(前缀已有) |
Placeholder / consistency self-review
- 函数名统一:
allocate_or_get_plan_copy_seq、ensure_copy_seq_for_driver、响应键copySeqNo。 - 迁移文件名与仓库惯例一致:
docs/sql/migrate_*_YYYYMMDD.sql(非migrations/子目录)。 - 无 TBD;SQLite
with_for_update风险已在 Task 2 Step 4 写明处理方式。