Преглед изворни кода

docs: add implementation plan for plan deliveryTimes column

Break the approved design into TDD tasks across filters, schema,
export, and the four plan-family list pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong пре 1 месец
родитељ
комит
de7d5194ad
1 измењених фајлова са 735 додато и 0 уклоњено
  1. 735 0
      docs/superpowers/plans/2026-09-12-pl-plan-delivery-times-column.md

+ 735 - 0
docs/superpowers/plans/2026-09-12-pl-plan-delivery-times-column.md

@@ -0,0 +1,735 @@
+# 运输计划列表「送货时间」列 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:** 在运输计划族四页列表「计划日期」后展示去重升序的送货段日期(`deliveryTimes`),并支持列头区间过滤(任一送货段命中)。
+
+**Architecture:** 在 `build_plan_column_filters` 增加对 `pl_plan_delivery.delivery_time` 的 EXISTS 过滤;列表装配时从已加载送货段聚合 `deliveryTimes: string[]`;四页 Vue 增加合并列 + 列头漏斗;计划维度导出在「计划日期」后增加「送货时间」(` / ` 拼接)。不使用、不改写 `pl_plan.delivery_time`。
+
+**Tech Stack:** FastAPI · SQLAlchemy · Pydantic v2 · Vue 3 · Element Plus · pytest
+
+**Spec:** `docs/superpowers/specs/2026-09-12-pl-plan-delivery-times-column-design.md`
+
+**提交约定:** 默认每个 Task 末尾 commit;若用户要求暂不提交,可攒到全部完成后再提交。
+
+---
+
+## 文件地图
+
+| 职责 | 路径 |
+|------|------|
+| 过滤 EXISTS | Modify `backend/app/services/pl/plan_list_filters.py` |
+| 过滤单测 | Modify `backend/tests/test_pl_plan_list_filters.py` |
+| 聚合 helper + attach | Modify `backend/app/services/pl_plan_stop_service.py` |
+| 聚合单测 | Create `backend/tests/test_pl_delivery_times_aggregate.py` |
+| Schema | Modify `backend/app/schemas/pl/plan.py`(`PlanWithProducts`、`PlanContractGapRead`) |
+| List/Export/Gaps API | Modify `backend/app/api/pl/plan.py` |
+| Gaps service 过滤+字段 | Modify `backend/app/services/pl_plan_contract_gap.py` |
+| FE Plan 类型 | Modify `web-admin/src/api/pl/index.ts` |
+| FE 列定义 | Modify 四页 `columnFilterDefs.ts` |
+| FE 四页列表 | Modify `plan` / `plan-archive` / `plan-cert` / `plan-contract` 的 `index.vue` |
+
+**不做:** DB 迁移、小程序、归档段次展开导出加聚合列、改顶部日期 pills。
+
+---
+
+### Task 1: 过滤 EXISTS + 单测
+
+**Files:**
+- Modify: `backend/app/services/pl/plan_list_filters.py`
+- Modify: `backend/tests/test_pl_plan_list_filters.py`
+
+- [ ] **Step 1: 写失败单测**
+
+在 `backend/tests/test_pl_plan_list_filters.py` 末尾追加:
+
+```python
+def test_delivery_time_range_exists_requires_db():
+    filters = build_plan_column_filters(
+        db=None,
+        tenant_no="shanxia2.0",
+        delivery_time_from=date(2026, 9, 12),
+        delivery_time_to=date(2026, 9, 14),
+    )
+    assert filters == []
+
+
+def test_delivery_time_range_exists_sql(monkeypatch):
+    class _FakeQuery:
+        def __init__(self):
+            self.filters = []
+
+        def filter(self, *args):
+            self.filters.extend(args)
+            return self
+
+        def exists(self):
+            return ("EXISTS", tuple(self.filters))
+
+    class _FakeDb:
+        def query(self, *_args, **_kwargs):
+            return _FakeQuery()
+
+    filters = build_plan_column_filters(
+        db=_FakeDb(),
+        tenant_no="shanxia2.0",
+        delivery_time_from=date(2026, 9, 12),
+        delivery_time_to="2026-09-14",
+    )
+    assert len(filters) == 1
+    kind, clauses = filters[0]
+    assert kind == "EXISTS"
+    compiled = " ".join(_compile(c) for c in clauses)
+    assert "pl_plan_delivery" in compiled or "delivery_time" in compiled
+    assert ">=" in compiled
+    assert "<=" in compiled
+```
+
+说明:若实现选用 SQLAlchemy `exists()` 表达式而非假 query 元组,将 `test_delivery_time_range_exists_sql` 改为对真实 `exists` clause 做 `_compile`,断言含 `exists`、`delivery_time`、`>=`、`<=`、`del_flag`(与现有 product_name exists 测法一致即可)。**以能编译出 EXISTS 子查询且不引用 `pl_plan.delivery_time` 为准。**
+
+- [ ] **Step 2: 跑测确认失败**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_pl_plan_list_filters.py::test_delivery_time_range_exists_requires_db tests/test_pl_plan_list_filters.py::test_delivery_time_range_exists_sql -v
+```
+
+Expected: FAIL(未知参数或断言失败)
+
+- [ ] **Step 3: 实现过滤**
+
+在 `plan_list_filters.py`:
+
+1. Import `PlPlanDelivery`:
+```python
+from app.models.pl_models import PlPlan, PlPlanProduct, PlPlanDelivery
+```
+
+2. 增加 helper(推荐真实 exists,便于编译断言):
+
+```python
+def plan_delivery_time_exists(
+    db: Session,
+    tenant_no: str,
+    *,
+    d_from: Optional[date] = None,
+    d_to: Optional[date] = None,
+):
+    """任一未删除送货段的送货日历日落在 [from, to] 内。"""
+    q = db.query(PlPlanDelivery.id).filter(
+        PlPlanDelivery.plan_id == PlPlan.id,
+        PlPlanDelivery.tenant_no == tenant_no,
+        PlPlanDelivery.del_flag == 0,
+        PlPlanDelivery.delivery_time.isnot(None),
+    )
+    if d_from is not None:
+        q = q.filter(func.date(PlPlanDelivery.delivery_time) >= d_from)
+    if d_to is not None:
+        q = q.filter(func.date(PlPlanDelivery.delivery_time) <= d_to)
+    return q.exists()
+```
+
+3. `build_plan_column_filters` 增加参数并在 `plan_date` 过滤后追加:
+
+```python
+    delivery_time_from: DateLike = None,
+    delivery_time_to: DateLike = None,
+```
+
+```python
+    dt_from = _parse_date(delivery_time_from)
+    dt_to = _parse_date(delivery_time_to)
+    if (dt_from is not None or dt_to is not None) and db is not None:
+        filters.append(
+            plan_delivery_time_exists(
+                db, tenant_no, d_from=dt_from, d_to=dt_to
+            )
+        )
+```
+
+若采用真实 `exists()`,把 Step 1 的 fake-db 测试改成:
+
+```python
+def test_delivery_time_range_exists_sql():
+    from unittest.mock import MagicMock
+
+    db = MagicMock()
+    q = MagicMock()
+    db.query.return_value = q
+    q.filter.return_value = q
+    q.exists.return_value = "DELIVERY_TIME_EXISTS"
+
+    filters = build_plan_column_filters(
+        db=db,
+        tenant_no="shanxia2.0",
+        delivery_time_from=date(2026, 9, 12),
+        delivery_time_to="2026-09-14",
+    )
+    assert filters == ["DELIVERY_TIME_EXISTS"]
+    assert db.query.called
+    # filter 调用中应包含 date 比较;至少确认 exists 被调用
+    assert q.exists.called
+```
+
+- [ ] **Step 4: 跑测通过**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_pl_plan_list_filters.py -v
+```
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/services/pl/plan_list_filters.py backend/tests/test_pl_plan_list_filters.py
+git commit -m "$(cat <<'EOF'
+feat(pl): filter plans by delivery-stop date range
+
+Add EXISTS on pl_plan_delivery.delivery_time for list column filters.
+EOF
+)"
+```
+
+---
+
+### Task 2: `deliveryTimes` 聚合 helper + 单测
+
+**Files:**
+- Modify: `backend/app/services/pl_plan_stop_service.py`
+- Create: `backend/tests/test_pl_delivery_times_aggregate.py`
+
+- [ ] **Step 1: 写失败单测**
+
+创建 `backend/tests/test_pl_delivery_times_aggregate.py`:
+
+```python
+from datetime import date, datetime
+
+from app.services.pl_plan_stop_service import collect_unique_delivery_date_strings
+
+
+class _D:
+    def __init__(self, delivery_time):
+        self.delivery_time = delivery_time
+
+
+def test_collect_unique_sorted_yyyy_mm_dd():
+    rows = [
+        _D(datetime(2026, 9, 14, 10, 0, 0)),
+        _D(datetime(2026, 9, 12, 8, 0, 0)),
+        _D(date(2026, 9, 12)),
+        _D(None),
+        _D(""),
+        _D("2026-09-13"),
+    ]
+    assert collect_unique_delivery_date_strings(rows) == [
+        "2026-09-12",
+        "2026-09-13",
+        "2026-09-14",
+    ]
+
+
+def test_collect_empty():
+    assert collect_unique_delivery_date_strings([]) == []
+    assert collect_unique_delivery_date_strings([_D(None)]) == []
+```
+
+- [ ] **Step 2: 跑测确认失败**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_pl_delivery_times_aggregate.py -v
+```
+
+Expected: FAIL(ImportError / 函数不存在)
+
+- [ ] **Step 3: 实现 helper**
+
+在 `pl_plan_stop_service.py` 合适位置(`attach_plan_stops_to_item` 附近)增加:
+
+```python
+def collect_unique_delivery_date_strings(deliveries) -> list[str]:
+    """送货段 delivery_time → 去重升序 YYYY-MM-DD。"""
+    days: set[date] = set()
+    for row in deliveries or []:
+        raw = getattr(row, "delivery_time", None)
+        if raw is None or raw == "":
+            continue
+        if isinstance(raw, datetime):
+            days.add(raw.date())
+        elif isinstance(raw, date):
+            days.add(raw)
+        else:
+            text = str(raw).strip()
+            if not text:
+                continue
+            try:
+                days.add(datetime.strptime(text[:10], "%Y-%m-%d").date())
+            except ValueError:
+                continue
+    return [d.isoformat() for d in sorted(days)]
+```
+
+确保文件顶部已有 `date` / `datetime` import(已有则勿重复)。
+
+- [ ] **Step 4: 跑测通过**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_pl_delivery_times_aggregate.py -v
+```
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/services/pl_plan_stop_service.py backend/tests/test_pl_delivery_times_aggregate.py
+git commit -m "$(cat <<'EOF'
+feat(pl): aggregate unique delivery dates from stop rows
+
+Provide YYYY-MM-DD dedupe helper for plan list deliveryTimes.
+EOF
+)"
+```
+
+---
+
+### Task 3: Schema + attach 写入 `deliveryTimes`
+
+**Files:**
+- Modify: `backend/app/schemas/pl/plan.py`
+- Modify: `backend/app/services/pl_plan_stop_service.py`(`attach_plan_stops_to_item`)
+- Modify: `backend/app/services/pl_plan_contract_gap.py`(`_build_gap_item` 或等价构造函数)
+
+- [ ] **Step 1: Schema 增加字段**
+
+在 `PlanWithProducts`(约 `delivery_customer_summary` 后)增加:
+
+```python
+    delivery_times: list[str] = Field(
+        default_factory=list,
+        alias="deliveryTimes",
+        description="送货段送货时间去重升序 YYYY-MM-DD(列表展示)",
+    )
+```
+
+在 `PlanContractGapRead` 增加同样字段(`deliveries` 字段附近):
+
+```python
+    delivery_times: list[str] = Field(
+        default_factory=list,
+        alias="deliveryTimes",
+        description="送货段送货时间去重升序 YYYY-MM-DD",
+    )
+```
+
+- [ ] **Step 2: attach 时写入**
+
+`attach_plan_stops_to_item` 末尾:
+
+```python
+    plan_item.delivery_times = collect_unique_delivery_date_strings(delivery_reads)
+```
+
+(若 Pydantic model 用 alias,赋值用 `delivery_times` 属性名。)
+
+- [ ] **Step 3: 合同缺口项写入**
+
+在构造 `PlanContractGapRead(...)` 处增加:
+
+```python
+        deliveryTimes=collect_unique_delivery_date_strings(deliveries),
+```
+
+或 `delivery_times=...`(与该处其它字段传参风格一致)。从 `pl_plan_stop_service` import `collect_unique_delivery_date_strings`。
+
+- [ ] **Step 4: 快速冒烟**
+
+```bash
+cd backend && source venv/bin/activate && python -c "
+from app.schemas.pl.plan import PlanWithProducts, PlanContractGapRead
+from app.services.pl_plan_stop_service import collect_unique_delivery_date_strings, attach_plan_stops_to_item
+print('ok', collect_unique_delivery_date_strings([]))
+"
+```
+
+Expected: 打印 `ok []`,无 ImportError
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/schemas/pl/plan.py backend/app/services/pl_plan_stop_service.py backend/app/services/pl_plan_contract_gap.py
+git commit -m "$(cat <<'EOF'
+feat(pl): expose deliveryTimes on plan list and contract-gap schemas
+
+Populate from delivery stops during attach/gap row build.
+EOF
+)"
+```
+
+---
+
+### Task 4: API 透传过滤参数 + 计划导出列
+
+**Files:**
+- Modify: `backend/app/api/pl/plan.py`
+- Modify: `backend/app/services/pl_plan_contract_gap.py`(`list_plan_contract_gaps` 签名与过滤)
+
+- [ ] **Step 1: `_filters_from_params` 透传**
+
+```python
+        delivery_time_from=_param_get(params, "deliveryTimeFrom", "delivery_time_from"),
+        delivery_time_to=_param_get(params, "deliveryTimeTo", "delivery_time_to"),
+```
+
+- [ ] **Step 2: `list_plans` Query + `_merge_query`**
+
+在 `plan_date_to` Query 旁增加:
+
+```python
+    delivery_time_from: Optional[date] = Query(
+        default=None, alias="deliveryTimeFrom", description="送货时间开始"
+    ),
+    delivery_time_to: Optional[date] = Query(
+        default=None, alias="deliveryTimeTo", description="送货时间结束"
+    ),
+```
+
+并在 `_merge_query(...)` 调用处传入 `deliveryTimeFrom=delivery_time_from`、`deliveryTimeTo=delivery_time_to`。
+
+证书列表若走同一 `list_plans`,无需另改。
+
+- [ ] **Step 3: Export body + 计划维度导出填充**
+
+`PlanExportBody` 增加(可依赖 `extra="allow"`,但显式字段更清晰):
+
+```python
+    delivery_time_from: Optional[date] = Field(default=None, alias="deliveryTimeFrom")
+    delivery_time_to: Optional[date] = Field(default=None, alias="deliveryTimeTo")
+```
+
+`PLAN_EXPORT_COLUMNS` 在计划日期后插入:
+
+```python
+    ExportColumn(title="计划日期", field="planDate"),
+    ExportColumn(title="送货时间", field="deliveryTimes"),
+```
+
+非 archive 导出循环中,在 `model_validate(plan)` 后批量加载 stops(页内已有 `load_plan_stops_grouped` 用法):
+
+```python
+            plan_ids = [p.id for p in plans]
+            stops_by_plan = load_plan_stops_grouped(db, tenant_no, plan_ids)
+            items = []
+            for plan in plans:
+                data = PlanWithProducts.model_validate(plan).model_dump(by_alias=True)
+                if data.get("planDate") is not None:
+                    data["planDate"] = str(data["planDate"])[:10]
+                _, delivery_reads = stops_by_plan.get(plan.id, (None, []))
+                times = collect_unique_delivery_date_strings(delivery_reads)
+                data["deliveryTimes"] = " / ".join(times) if times else ""
+                if data.get("erpEntered") is not None:
+                    data["erpEntered"] = "是" if int(data["erpEntered"]) else "否"
+                items.append(data)
+```
+
+从 `pl_plan_stop_service` import `collect_unique_delivery_date_strings`(若尚未导入)。**不要**改 `PLAN_ARCHIVE_EXPORT_COLUMNS`(段次展开已有「段次时间」)。
+
+- [ ] **Step 4: 合同缺口 list/export 透传**
+
+`list_contract_gaps` / `export_contract_gaps`:Query/Body + 调用 `list_plan_contract_gaps(..., delivery_time_from=..., delivery_time_to=...)`。
+
+在 `list_plan_contract_gaps` 内,于 `plan_date` 过滤附近增加:
+
+```python
+    from app.services.pl.plan_list_filters import plan_delivery_time_exists, _parse_date
+
+    dt_from = _parse_date(delivery_time_from)
+    dt_to = _parse_date(delivery_time_to)
+    if dt_from is not None or dt_to is not None:
+        query = query.filter(
+            plan_delivery_time_exists(db, tenant_no, d_from=dt_from, d_to=dt_to)
+        )
+```
+
+(若 `_parse_date` 不想导出,可在 gap 服务内复用 `build_plan_column_filters` 只取 delivery 条件,或把 `_parse_date` 升为模块公开函数;优先直接调用 `build_plan_column_filters` 把返回的 filters `query.filter(*filters)`,但注意不要重复已有 plan_no 等条件——**仅追加 delivery 相关 EXISTS**:调用 `plan_delivery_time_exists` 最干净。)
+
+`CONTRACT_GAP_EXPORT_COLUMNS` 在计划日期后加「送货时间」;export 行把 `deliveryTimes` list 转 `" / ".join(...)`。
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/api/pl/plan.py backend/app/services/pl_plan_contract_gap.py
+git commit -m "$(cat <<'EOF'
+feat(pl): wire deliveryTimeFrom/To through plan list and export
+
+Contract-gap list/export share the same stop-date EXISTS filter.
+EOF
+)"
+```
+
+---
+
+### Task 5: 前端类型 + 四页 `columnFilterDefs`
+
+**Files:**
+- Modify: `web-admin/src/api/pl/index.ts`
+- Modify: `web-admin/src/views/pl/plan/columnFilterDefs.ts`
+- Modify: `web-admin/src/views/pl/plan-archive/columnFilterDefs.ts`
+- Modify: `web-admin/src/views/pl/plan-cert/columnFilterDefs.ts`
+- Modify: `web-admin/src/views/pl/plan-contract/columnFilterDefs.ts`
+
+- [ ] **Step 1: Plan / Gap 类型**
+
+在 `Plan` interface(`planDate` 附近)增加:
+
+```typescript
+  deliveryTimes?: string[]
+```
+
+若合同缺口有独立 interface(搜 `PlanContractGap` / gap 列表类型),同样加 `deliveryTimes?: string[]`。
+
+- [ ] **Step 2: 四页 columnFilterDefs**
+
+在各文件的 `planDate` 定义后插入:
+
+```typescript
+  {
+    prop: 'deliveryTimes',
+    label: '送货时间',
+    type: 'datetime',
+    queryKeyFrom: 'deliveryTimeFrom',
+    queryKeyTo: 'deliveryTimeTo'
+  },
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web-admin/src/api/pl/index.ts \
+  web-admin/src/views/pl/plan/columnFilterDefs.ts \
+  web-admin/src/views/pl/plan-archive/columnFilterDefs.ts \
+  web-admin/src/views/pl/plan-cert/columnFilterDefs.ts \
+  web-admin/src/views/pl/plan-contract/columnFilterDefs.ts
+git commit -m "$(cat <<'EOF'
+feat(web-admin): add deliveryTimes column filter defs for plan lists
+
+Align query keys with deliveryTimeFrom/To server filters.
+EOF
+)"
+```
+
+---
+
+### Task 6: 主列表 `plan/index.vue` UI
+
+**Files:**
+- Modify: `web-admin/src/views/pl/plan/index.vue`
+
+- [ ] **Step 1: 合并列与列设置**
+
+`PLAN_MERGE_COLUMN_PROPS` 在 `'planDate'` 后插入 `'deliveryTimes'`。
+
+列设置数组 `{ prop: 'planDate', label: '计划日期' }` 后插入:
+
+```typescript
+    { prop: 'deliveryTimes', label: '送货时间' },
+```
+
+- [ ] **Step 2: 表格列(计划日期列后)**
+
+```vue
+            <el-table-column
+              v-if="isColumnVisible('deliveryTimes')"
+              prop="deliveryTimes"
+              label="送货时间"
+              min-width="130"
+              align="center"
+            >
+              <template #header>
+                <div class="col-header-with-filter">
+                  <span class="col-header-with-filter__label">送货时间</span>
+                  <ColumnHeaderFilter
+                    prop="deliveryTimes"
+                    label="送货时间"
+                    type="datetime"
+                    :is-active="isFilterActive('deliveryTimes')"
+                    :model-value="filters.deliveryTimes || null"
+                    @update:model-value="(v) => setFilterState('deliveryTimes', v)"
+                  />
+                </div>
+              </template>
+              <template #default="{ row }">
+                <div
+                  v-if="(row.plan.deliveryTimes || []).length"
+                  class="pl-delivery-times-cell"
+                >
+                  <div
+                    v-for="(t, i) in row.plan.deliveryTimes"
+                    :key="`${row.plan.id}-dt-${i}`"
+                  >
+                    {{ t }}
+                  </div>
+                </div>
+                <span v-else class="text-muted">—</span>
+              </template>
+            </el-table-column>
+```
+
+若项目无 `.text-muted`,用现有 muted 类或内联 `color: var(--mint-text-muted)`。
+
+- [ ] **Step 3: `getDisplayStatCellValue`**
+
+```typescript
+    if (prop === 'deliveryTimes') {
+      return (plan.deliveryTimes || []).join('\n') || '—'
+    }
+```
+
+(合并键用字符串化结果,保证同计划多行一致。)
+
+- [ ] **Step 4: 样式**
+
+scoped 中:
+
+```scss
+  .pl-delivery-times-cell {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 2px;
+    line-height: 1.35;
+  }
+```
+
+- [ ] **Step 5: 确认 pills 只清 planDate**
+
+核对 `handleDateShortcut` 仅 `delete filters.planDate`,**不要**删除 `filters.deliveryTimes`。
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add web-admin/src/views/pl/plan/index.vue
+git commit -m "$(cat <<'EOF'
+feat(web-admin): show deliveryTimes column on transport plan list
+
+Multi-line YYYY-MM-DD with header range filter after planDate.
+EOF
+)"
+```
+
+---
+
+### Task 7: 归档 / 证书列表 UI(与主列表同构)
+
+**Files:**
+- Modify: `web-admin/src/views/pl/plan-archive/index.vue`
+- Modify: `web-admin/src/views/pl/plan-cert/index.vue`
+
+- [ ] **Step 1: 两页均做与 Task 6 相同改动**
+
+- `PLAN_MERGE_COLUMN_PROPS` 插入 `deliveryTimes`
+- 列设置插入
+- `el-table-column`(数据源仍为 `row.plan.deliveryTimes`)
+- `getDisplayStatCellValue`(或等价)处理 `deliveryTimes`
+- 同样 cell 样式类
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add web-admin/src/views/pl/plan-archive/index.vue web-admin/src/views/pl/plan-cert/index.vue
+git commit -m "$(cat <<'EOF'
+feat(web-admin): add deliveryTimes column on plan archive and cert lists
+
+Match main plan list display and column-header filter behavior.
+EOF
+)"
+```
+
+---
+
+### Task 8: 合同完善列表 UI + 导出字段
+
+**Files:**
+- Modify: `web-admin/src/views/pl/plan-contract/index.vue`
+
+- [ ] **Step 1: 合并与列**
+
+`PLAN_MERGE_COLUMN_PROPS`:`['planDate', 'deliveryTimes', 'planType', 'followStatus']`(或在 `planDate` 后插入)。
+
+表格在计划日期后增加列;单元格取 `asGapItem(row).deliveryTimes`(与该页 `planDate` 取值方式一致)。
+
+列头过滤 defs 已在 Task 5;确保 `useServerColumnFilters` 使用的 defs 含 `deliveryTimes`,且 buildParams 会带上 `deliveryTimeFrom/To`。
+
+- [ ] **Step 2: 查看弹窗(可选一致性)**
+
+若补充信息 KV 有「计划日期」,在其后加「送货时间」多行或 ` / ` 拼接;无则跳过(YAGNI)。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web-admin/src/views/pl/plan-contract/index.vue
+git commit -m "$(cat <<'EOF'
+feat(web-admin): add deliveryTimes column on plan contract-gap list
+
+Filter and display deduped delivery dates beside planDate.
+EOF
+)"
+```
+
+---
+
+### Task 9: 回归验证 + 图谱更新
+
+- [ ] **Step 1: 后端测试**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_pl_plan_list_filters.py tests/test_pl_delivery_times_aggregate.py -v
+```
+
+Expected: PASS
+
+- [ ] **Step 2: 手测清单(对照 spec §4)**
+
+1. `/#/pl/plan`:计划日期后有送货时间;多段同日去重;多日多行 `YYYY-MM-DD`
+2. 列头送货时间区间:任一命中;顶部 pills 仍只动计划日期,不清送货时间过滤
+3. 归档 / 证书 / 合同完善行为一致
+4. 计划列表导出有「送货时间」,多值 ` / ` 分隔
+5. 无送货时间显示 `—`,送货时间过滤不误命中
+
+- [ ] **Step 3: 更新知识图谱**
+
+```bash
+./scripts/update-graph.sh backend
+./scripts/update-graph.sh frontend
+```
+
+或按仓库约定:`graphify update .`(若脚本封装不同,以 `AGENTS.md` 为准)。
+
+- [ ] **Step 4: Commit 图谱(若有 diff)**
+
+```bash
+git add backend/graphify-out web-admin/graphify-out graphify-out 2>/dev/null || true
+git status -sb
+# 仅当有图谱文件变更时:
+git commit -m "$(cat <<'EOF'
+chore: update graphify after deliveryTimes list column
+EOF
+)"
+```
+
+---
+
+## Self-Review(对照 spec)
+
+| Spec 项 | Task |
+|---------|------|
+| EXISTS 任一命中过滤 | Task 1、4 |
+| `deliveryTimes[]` 去重升序 YYYY-MM-DD | Task 2、3 |
+| 不用 `pl_plan.delivery_time` | Task 1/2 数据源仅 delivery 表/段 |
+| 四页 UI + 合并列 + 列头漏斗 | Task 5–8 |
+| pills 只筛计划日期 | Task 6 Step 5 |
+| 计划维度导出 + 段次导出不加聚合列 | Task 4 |
+| 合同缺口同源 | Task 3、4、8 |
+| 无 DB 迁移 | 全计划无 DDL |
+
+无 TBD 占位;命名统一 `deliveryTimes` / `deliveryTimeFrom` / `deliveryTimeTo` / `collect_unique_delivery_date_strings`。