|
|
@@ -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 写明处理方式。
|