From de7d5194adaa4372844a29ba7b36f0c8c070ceab Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Sat, 12 Sep 2026 21:05:41 +0800 Subject: [PATCH] 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 --- ...026-09-12-pl-plan-delivery-times-column.md | 735 ++++++++++++++++++ 1 file changed, 735 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-12-pl-plan-delivery-times-column.md diff --git a/docs/superpowers/plans/2026-09-12-pl-plan-delivery-times-column.md b/docs/superpowers/plans/2026-09-12-pl-plan-delivery-times-column.md new file mode 100644 index 000000000..0d6cf60bd --- /dev/null +++ b/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 + + + + +``` + +若项目无 `.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`。