Explorar o código

feat(pl): allocate copy_seq_no on first driver plan share-to-group

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>
wuzilong hai 3 semanas
pai
achega
811e6a129f

+ 22 - 0
backend/app/api/pl/driver_plan.py

@@ -82,6 +82,28 @@ def get_driver_plan_detail(
     return success_response(page.model_dump(by_alias=True))
 
 
+@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})
+
+
 @router.post("/{plan_id}/feedback")
 def save_driver_plan_feedback(
     plan_id: str,

+ 50 - 0
backend/app/services/pl_driver_plan_service.py

@@ -111,6 +111,56 @@ def resolve_transport_for_driver_plan(
     return plan, transport
 
 
+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
+
+
 def _build_driver_url(token: str) -> str:
     origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/")
     return f"{origin}/#/public/pl/transport/{token}"

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 341 - 323
backend/graphify-out/GRAPH_REPORT.md


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 112 - 112
backend/graphify-out/graph.json


+ 105 - 0
backend/tests/test_pl_plan_copy_seq.py

@@ -0,0 +1,105 @@
+"""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

+ 570 - 0
docs/superpowers/plans/2026-10-09-pl-plan-copy-seq.md

@@ -0,0 +1,570 @@
+# 运输计划「复制发群」固定序号 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_plan` CREATE 中增加同列;搜索 ``CREATE TABLE `pl_plan` ``)
+
+- [ ] **Step 1: 写迁移 SQL**
+
+```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` 后增加:
+
+```python
+copy_seq_no = Column(Integer, nullable=True, comment="复制发群序号(租户内固定流水)")
+```
+
+(文件顶部已有 `Integer` import 则复用。)
+
+- [ ] **Step 3: 同步 `docs/sql/shanxia2.0.sql` 中 `pl_plan` 表结构**
+
+在 `` `plan_no` varchar(32) ... `` 下一行增加:
+
+```sql
+  `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**
+
+```bash
+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`:
+
+```python
+"""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: 跑测试确认失败**
+
+```bash
+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` 顶部确保有:
+
+```python
+from sqlalchemy import and_, func, or_
+```
+
+(已有 `func` 则不重复。)增加:
+
+```python
+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: 跑测试确认通过**
+
+```bash
+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**
+
+```bash
+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` 之前插入:
+
+```python
+@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 调用:
+
+```bash
+# 示例:替换 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**
+
+```bash
+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` 旁增加:
+
+```javascript
+  ensureCopySeq: async (planId) =>
+    unwrapData(await db.post(`/transport/driver-plan/${planId}/ensure-copy-seq`, {})),
+```
+
+- [ ] **Step 2: 文案首行**
+
+改 `buildDriverPlanCopyText`,在 `lines` 最前插入序号(支持 `copySeqNo` / `copy_seq_no`):
+
+```javascript
+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**
+
+```bash
+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 失败则中止,不写剪贴板):
+
+```javascript
+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 再复制:
+
+```javascript
+        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: 手工验收清单**
+
+1. 执行迁移后,任选两条历史计划首次复制:序号应符合创建时间先后,且再次复制不变。
+2. 新建计划(不复制):库中 `copy_seq_no` 为 `NULL`。
+3. 对该新计划点「复制发群」:文案首行 `序号:{MAX+1}`,库中已写入。
+4. 列表与回执弹窗两条路径均成功;UI 上无序号展示。
+5. ensure 失败(断网/无权限)toast「复制失败」,剪贴板不更新为半成品(或保持原内容)。
+
+- [ ] **Step 4: Commit**
+
+```bash
+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 图谱:
+
+```bash
+./scripts/update-graph.sh backend
+```
+
+若脚本不存在,则在仓库根目录:`graphify update backend`(以 `AGENTS.md` / `graphify.mdc` 为准)。
+
+- [ ] **Step 3: Commit**
+
+```bash
+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 写明处理方式。

+ 138 - 0
docs/superpowers/specs/2026-10-09-pl-plan-copy-seq-design.md

@@ -0,0 +1,138 @@
+# 运输计划「复制发群」固定序号设计
+
+> **日期**: 2026-10-09  
+> **状态**: 已确认  
+> **范围**: `pl_plan.copy_seq_no` + `POST /api/transport/driver-plan/{planId}/ensure-copy-seq` + 小程序司机运输计划单「复制发群」文案  
+> **前置**: `docs/superpowers/specs/2026-09-28-mini-pl-driver-plan-copy-wechat-design.md`
+
+## 1. 目标
+
+在司机运输计划单「复制发群」纯文本中,于「计划编号」上方增加固定流水「序号」。租户内永不回收;历史按创建时间回填;**新建计划不在创建时发号,首次点击复制时发号并落库**。
+
+## 2. 已确认决策
+
+| # | 项 | 决策 |
+|---|-----|------|
+| D1 | 号段范围 | 按 `tenant_no` 永久累加 1、2、3… |
+| D2 | 回收 | 作废 / 软删 **不回收**号;回填与 `MAX` 均含 `del_flag=1` |
+| D3 | 展示面 | **仅**「复制发群」文案;列表/详情/Web 管理端不展示 |
+| D4 | 发号时机 | 创建时 `NULL`;**首次**「复制发群」调用后端 ensure 时分配并写库 |
+| D5 | 历史 | 迁移按租户 `create_time ASC, id ASC` 一次性补号;含软删 |
+| D6 | 无号兜底 | 文案写 `序号:—`(正常流程点复制会先 ensure,几乎用不到) |
+| D7 | 格式 | `序号:{n}`,纯数字不补零 |
+| D8 | 详情副作用 | **禁止**在普通 `GET` 详情里发号;专用 `POST ensure-copy-seq` |
+
+## 3. 非目标
+
+- 不在 Web 管理端 / 小程序列表卡片 / 回执页 UI 展示该序号
+- 不改变装货方、送货方、实际装/送货量等既有复制规则
+- 不按日/月重置号段
+- 创建计划路径(`create_plan` / `create_plan_full` / `create_from_contract` 等)**不**分配 `copy_seq_no`
+
+## 4. 数据模型
+
+### 4.1 字段
+
+`pl_plan` 新增:
+
+| 列 | 类型 | 空 | 说明 |
+|----|------|----|------|
+| `copy_seq_no` | `INT` | YES | 复制发群序号(租户内固定流水);未首次复制前为 `NULL`(历史回填后有值) |
+
+建议索引:`KEY idx_pl_plan_tenant_copy_seq (tenant_no, copy_seq_no)`(便于 `MAX`)。
+
+### 4.2 历史回填
+
+- 分组:`tenant_no`
+- 排序:`create_time ASC, id ASC`
+- 范围:**含** `del_flag = 1`
+- 赋值:组内从 1 起连续编号写入 `copy_seq_no`
+- 幂等:已非空的行不覆盖(便于脚本重跑)
+
+### 4.3 运行时发号
+
+在事务内:
+
+1. 按与司机详情相同的可见性解析计划;不可见则失败。
+2. `SELECT … FROM pl_plan WHERE id=? FOR UPDATE`(或等价行锁)。
+3. 若 `copy_seq_no` 已有值 → 原样返回。
+4. 否则计算  
+   `next = COALESCE(MAX(copy_seq_no) WHERE tenant_no = ?, 含软删), 0) + 1`  
+   写回当前行并 `commit`,返回 `next`。
+
+同一计划并发首次复制只得到一个号;不同计划不撞号。
+
+## 5. API
+
+`POST /api/transport/driver-plan/{planId}/ensure-copy-seq`
+
+| 项 | 约定 |
+|----|------|
+| 鉴权 | JWT;与 `GET /transport/driver-plan/{planId}` 一致(须关联驾押人员且可看该计划) |
+| 成功体 | `{ "copySeqNo": <int> }`(camelCase / `by_alias` 与现网一致) |
+| 失败 | 未关联人员 / 计划不可见 → 与详情同等 404 或业务错误码 |
+| 加密排除 | 前缀已在 `/api/transport/driver-plan`,一般无需改配置 |
+
+详情 `GET` **可不**强制返回 `copySeqNo`;小程序以 ensure 结果为准合并进复制上下文。
+
+## 6. 复制文案
+
+在既有模板**最上方**增加一行:
+
+```
+序号:12
+计划编号:TP2026xxxx
+装货方:…
+送货方#1:…
+…
+```
+
+规则:
+
+- 有号:`序号:{copySeqNo}`
+- 无号:`序号:—`
+- 其余字段规则不变(见 2026-09-28 设计)
+
+## 7. 小程序交互
+
+### 列表「复制发群」
+
+1. `showLoading`
+2. `GET /transport/driver-plan/{planId}` 取详情
+3. `POST .../ensure-copy-seq` → 将 `copySeqNo` 合并进详情对象(如 `page.copySeqNo`)
+4. `buildDriverPlanCopyText` → `setClipboardData`
+5. `hideLoading`;成功「已复制」;任一步失败「复制失败」/「计划无效」
+
+说明:若 ensure 失败,仍可用详情尝试复制(序号行为 `—`),或整单失败——**推荐整单失败并 toast「复制失败」**,避免发出无序号的半成品(与「点复制就会有号」预期一致)。实现时采用:**ensure 失败则中止复制**。
+
+### 回执保存成功 →「复制发群」
+
+同样:ensure → 合并 → 复制;成功仍 `navigateBack`;失败 toast 且留在回执页。
+
+## 8. 实现结构
+
+| 位置 | 职责 |
+|------|------|
+| `docs/sql/migrations/2026-10-09-pl-plan-copy-seq-no.sql`(名可微调) | `ALTER` + 回填 |
+| `backend/app/models/pl_models.py` | `PlPlan.copy_seq_no` |
+| `backend/app/services/pl_driver_plan_service.py`(或邻近 service) | `ensure_copy_seq_no` |
+| `backend/app/api/pl/driver_plan.py` | 新路由 |
+| `backend/app/schemas/pl/…` | 响应模型(如需) |
+| `miniprogram/api/pl.js` | `driverPlanApi.ensureCopySeq` |
+| `miniprogram/utils/plDriverPlanCopy.js` | 首行序号 |
+| `miniprogram/pages/pl/driver-plan/list.vue` | `handleCopy` 调 ensure |
+| `miniprogram/pages/pl/driver-plan/detail.vue` | 保存成功复制路径调 ensure |
+
+## 9. 测试要点
+
+- 历史回填后:最旧计划为 1,同租户连续;软删行也占号
+- 新计划创建后 `copy_seq_no` 为 `NULL`;首次复制后为 `MAX+1` 且再次复制不变
+- 两客户端同时首次复制同一计划:只出现一个号
+- 两计划几乎同时首次复制:号不重复
+- 复制文案首行为 `序号:n`,其后为原「计划编号」块
+- ensure 失败时不写入剪贴板(或明确 toast),列表/详情 UI 仍无序号展示
+- 不可见计划 / 未绑定驾押人员:ensure 与 GET 详情一致失败
+
+## 10. 与加油登记序号的差异
+
+加油登记 `seq_no` 为按 `create_time` **现算**、不落库。本需求为 **落库固定流水**,且新单在**首次复制**时分配,不可套用加油登记的纯计算方案。

+ 2 - 0
miniprogram/api/pl.js

@@ -148,6 +148,8 @@ export const driverPlanApi = {
     return data?.counts && typeof data.counts === 'object' ? data.counts : {}
   },
   get: async (planId) => unwrapData(await db.get(`/transport/driver-plan/${planId}`)),
+  ensureCopySeq: async (planId) =>
+    unwrapData(await db.post(`/transport/driver-plan/${planId}/ensure-copy-seq`, {})),
   saveFeedback: (planId, payload) =>
     db.post(`/transport/driver-plan/${planId}/feedback`, payload),
   saveDriverFeedback: (planId, payload) =>

+ 17 - 3
miniprogram/pages/pl/driver-plan/detail.vue

@@ -281,9 +281,23 @@ function promptCopyAfterSave() {
       cancelText: '关闭',
       success: async (res) => {
         if (res.confirm) {
-          const ok = await copyDriverPlanToClipboard(page.value || {})
-          if (ok) {
-            setTimeout(() => goList(), 400)
+          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' })
           }
         }
         resolve()

+ 7 - 2
miniprogram/pages/pl/driver-plan/list.vue

@@ -240,9 +240,14 @@ async function handleCopy(row) {
   }
   uni.showLoading({ title: '加载中...', mask: true })
   try {
-    const page = await driverPlanApi.get(id)
+    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 || {})
+    await copyDriverPlanToClipboard({ ...page, copySeqNo })
   } catch (e) {
     uni.hideLoading()
     uni.showToast({ title: '复制失败', icon: 'none' })

+ 4 - 0
miniprogram/utils/plDriverPlanCopy.js

@@ -42,7 +42,11 @@ export function buildDriverPlanCopyText(page = {}) {
   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}`
   ]

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio