# 整改方案（可直接照着改代码）

> 依据：`grok-问题审核意见.md`（源码核验后的结论，不是主清单原文）。
> 原则：**保守最小改动把逻辑走通**；不重构状态机为双轨表、不改「两段式备课」设计；每个改动标明是否影响现有测试、是否必须 `npm run build`。
> 优先级：P0 必修（走不通 / 业务闸门洞）→ P1 建议修 → P2 可选。

**同批约束（必读）**：**P0-1（确认材料按钮）与 P0-4（上课标记）必须同一发布批次。** 只做 P0-1 时，`completed` 仍在 `TAUGHT_PLAN_STATUSES` 里，教师一点「完成备课」工作台就会催「待反馈」。

---

## 总览

| 编号 | 对应审核 | 简述 | 优先级 | 迁移 | 估时 | 影响测试 | 必须 build |
|---|---|---|---|---|---|---|---|
| P0-1 | A1, L2, L5 | 材料区补「确认材料（完成备课）」+ 材料非空/G1 | P0 | 否 | 2.5h | 需补/改 p6 周边，现有 UI 无测 | **是** |
| P0-2 | A2, L1 | 封 `step=complete` 并给 materials 补闸 | P0 | 否 | 1.5h | 无现成 complete 单测；建议加 | **是**（confirm 路由） |
| P0-3 | C, L3, L4, L6 | RANK 冻结改 maxStatus；PPT 确认位独立显示 | P0 | 否 | 2.5h | **改 test-p6 一条断言语义** | **是** |
| P0-4 | B, L8 | 「已上课」与备课状态脱钩 | P0 | 否（meta JSON） | 3.5h | **必改 test-s3a** | **是** |
| P1-D | D, J.4 | 生成上下文只注入前序课时反馈 | P1 | 否 | 2h | 可能改 test-s3a 上下文用例 | 否（纯 lib，建议 build） |
| P1-G | G | 复制路径统一丢掉 `main_case_id` | P1 | 否 | 1h | 改/补 test-p6 | 否 |
| P1-J5 | J.5 | extract-drafts 列表隔离 | P1 | 否 | 1h | 若有 s2 测需对一下 | 是 |
| P1-J7 | J.7 | RSS fallback 默认关闭 | P1 | 否 | 0.5h | demo-s2b 显式 true，应仍过 | 否 |
| P1-H | H | 画像写回乐观锁 + 文档口径 | P1 | 否 | 3h | test-p8 写回用例 | 是 |
| P1-I | I | confirmed_by 拆分 | P1 | 否 | 1.5h | 导出相关测若断言确认人 | 是 |
| P1-E | E | 文档/schema 注释对齐并行状态机 | P1 | 否 | 1h | 无 | 否 |
| P2-F | F | 删除「周进度反写」承诺（不实现） | P2 | 否 | 0.5h | 无 | 否 |
| P2-J3 | J.3 | 补丁 validUntil（若产品要过期） | P2 | **是** | 3h | patches 测 | 是 |
| P2-J6 | J.6 | 样例覆盖语义写进说明书 | P2 | 否 | 0.5h | 无 | 否 |
| P2-I-reg | I | 废弃未接的 regenerate 路由说明 | P2 | 否 | 0.5h | 无 | 否 |
| P2-H-ui | H | 第一课明示「画像待问卷」 | P2 | 否 | 0.5h | 无 | 是 |
| P2-K | K, J.8 | 母版套用等明确不承诺 | P2 | 否 | 0 | 无 | 否 |

**P0 合计约 10 小时；P0+P1 约 20 小时；含 P2（不含 validUntil）约 22 小时；含 validUntil 约 25 小时。**

---

## P0-1　材料区补「确认材料（完成备课）」按钮

- **问题编号**：A1（主）、L2、L5
- **问题简述**：UI 没有把课时推到 `completed` 的入口；`confirm_materials` 还不校验材料非空。
- **涉及文件**：
  - `src/app/prepare/[classId]/lesson-dev-panel.tsx`（主改）
  - `src/app/api/lesson-plans/[id]/confirm/route.ts`（材料非空 + G1）
  - 可选文案：`src/app/prepare/[classId]/sample-publish-card.tsx`（完成后按钮自然亮起，可不改）

### 改动思路

#### 1) 后端：`step=materials` 补闸（与 P0-2 共用）

文件：`src/app/api/lesson-plans/[id]/confirm/route.ts`

在现有 `if (step === "plan") { checkG1 ... }` **之后**，把 G1 扩大到 materials（complete 在 P0-2 处理）：

```ts
if (step === "plan" || step === "materials") {
  const g1 = checkG1(planPayload);
  if (!g1.ok) {
    return NextResponse.json(
      { error: G1_NO_OBJECTIVES_MESSAGE, code: G1_ERROR_CODE, g1 },
      { status: 400 }
    );
  }
}
```

`step === "materials"` 分支改为：

```ts
} else if (step === "materials") {
  event = "confirm_materials";
  if (!draft.materials) {
    return NextResponse.json(
      { error: "须先生成材料草稿再确认", code: "need_materials" },
      { status: 400 }
    );
  }
  confirmedPatch.materials = draft.materials;
}
```

学时保持警告、不硬阻断（与现设计一致）。不在这里强制 PPT（并行语义，见 E）。

`nextStatus(..., "confirm_materials")` 已要求 `>= materials_draft`（`state.ts` L75–77）。若当前是 `ppt_confirmed` 但还没生成材料，会停在原状态——前端按钮应用 `status === "materials_draft"` 禁用，避免点了没动。

#### 2) 前端：确认函数 + 按钮

文件：`src/app/prepare/[classId]/lesson-dev-panel.tsx`

仿 `confirmPpt`（L366–394）新增：

```ts
async function confirmMaterials() {
  if (!planId || !bundle) return;
  if (!bundle.g1.ok) {
    setError({
      code: "g1_no_objectives",
      message: bundle.g1.message || "无课时目标不得完成备课",
    });
    return;
  }
  const materials = bundle.materials as { cases?: unknown[]; exercises?: unknown[] } | null;
  const empty =
    !materials ||
    ((Array.isArray(materials.exercises) ? materials.exercises.length : 0) === 0 &&
      (Array.isArray(materials.cases) ? materials.cases.length : 0) === 0);
  if (empty) {
    setError({ code: "need_materials", message: "须先生成材料草稿再确认" });
    return;
  }
  setError(null);
  onPending("confirm-materials");
  try {
    const res = await api<{ plan: PlanBundle }>(`/api/lesson-plans/${planId}/confirm`, {
      method: "PUT",
      body: JSON.stringify({ step: "materials" }),
    });
    setBundle(res.plan);
    setInfo("材料已确认，本课时备课完成（completed）。可导出并发布教研样例。");
    onMeta({ status: res.plan.status });
  } catch (e) {
    const payload = e instanceof ApiError ? (e.payload as { error?: string; code?: string }) : null;
    setError({
      code: payload?.code,
      message: payload?.error || (e instanceof Error ? e.message : "确认材料失败"),
    });
  } finally {
    onPending("");
  }
}
```

材料标签页按钮组（L844 的 `<div className="flex gap-2">`）在「生成材料草稿」旁增加：

```tsx
<Button
  type="button"
  size="sm"
  variant="secondary"
  onClick={() => void confirmMaterials()}
  disabled={
    busy ||
    !bundle?.materials ||
    bundle.status !== "materials_draft" ||
    Boolean(bundle.g1 && !bundle.g1.ok)
  }
>
  <Check className="h-3.5 w-3.5" />
  确认材料（完成备课）
</Button>
```

在按钮下用现有 `bundle.hours` 做超载红字（与教案确认弹窗 L951–957 同样式即可，不必新弹窗；若要清单，复用材料列表已在 L861–882）。

完成后：

- `SamplePublishCard` 已按 `status === "completed"` 解禁，无需改。
- 顶部 `ChaoxingFiveStepsCard`（L558）自动出现。
- `quality-view` 完成率 / 待审会开始变动（L5 顺带缓解）。

### 是否需要数据库迁移

**否。**

### 验证方式

1. `npm run build`（必做，改了 client 组件 + API）。
2. 手动：
   - 教案确认 → 生成材料草稿 → 材料页出现「确认材料（完成备课）」且可点。
   - 未生成材料时按钮禁用；直接调 API 无 materials 返回 400 `need_materials`。
   - 点确认后 status=`completed`，工作台完成率 +1，样例「发布」按钮可用，顶部五步卡出现。
   - 回归：教案确认、PPT 确认、导出 docx/pptx 路径不变。
3. 无专门前端单测。可选：在 `scripts/test-p6.ts` 或新脚本对 `nextStatus("materials_draft","confirm_materials")==="completed"`（该断言 state 里已隐含，现有 p6 未测 confirm_materials，建议补一行）。

### 影响现有测试

- `scripts/test-p6.ts` 不覆盖 confirm 路由，**现有断言应仍过**。
- `scripts/test-p10.ts` 只打 `step: "plan"`，不受影响。
- 演示脚本里 Prisma 直接写 `completed` 的仍可用。

### 预估工作量

2.5 小时。

---

## P0-2　封掉 `step=complete` 并收口闸门

- **问题编号**：A2、L1
- **问题简述**：`complete` 无 rank、无 G1、无材料检查，任意状态可 `completed`。须登录且拥有教案，是业务闸门洞不是未鉴权。
- **涉及文件**：
  - `src/lib/prepare/state.ts`（`complete` 加门槛）
  - `src/app/api/lesson-plans/[id]/confirm/route.ts`

### 改动思路（最小、可照抄）

**产品选择（本方案拍板）**：不保留第二条「一键完成」语义。`step=complete` **返回 400**，引导使用 `step=materials`。`nextStatus` 的 `"complete"` 仍留着以免内部误用直达，但加上与 `confirm_materials` 相同的 rank 门槛作为兜底。

#### 1) `state.ts` L78–79 改为

```ts
case "complete":
  if (statusRank(cur) < RANK.materials_draft) return cur;
  return "completed";
```

这样即使有人绕过路由直接调 `nextStatus(x, "complete")`，也不能从 `not_started` 跳完成。

#### 2) `confirm/route.ts` 的 complete 分支改为拒绝

替换 L100–104：

```ts
} else if (step === "complete") {
  return NextResponse.json(
    {
      error: "请使用 step=materials 确认材料以完成备课",
      code: "complete_removed",
    },
    { status: 400 }
  );
}
```

不要再走 `event = "complete"`。

若希望少打一次 400、把 complete 当别名（兼容万一的脚本），用下面这段 **二选一，不要两套都留**：

```ts
} else if (step === "complete") {
  // 别名：等同 materials，走同一套校验（G1 已在上方覆盖 complete）
  event = "confirm_materials";
  if (!draft.materials) {
    return NextResponse.json(
      { error: "须先生成材料草稿再确认", code: "need_materials" },
      { status: 400 }
    );
  }
  confirmedPatch.materials = draft.materials;
}
```

并让 G1 判断包含 `step === "complete"`。**推荐拒绝方案**，全仓 `grep` 无前端/脚本发 `step: "complete"`。

### 是否需要数据库迁移

**否。**

### 验证方式

1. `npm run build`。
2. 手动/curl（登录 cookie 下）：
   - `PUT /api/lesson-plans/:id/confirm` body `{"step":"complete"}` → 400 `complete_removed`。
   - `not_started` 的教案即使将来误调 `nextStatus(...,"complete")` 也保持原状（单测）。
   - `step=materials` 在无材料 / 无 objectives 时 400。
3. 建议在 `scripts/test-p6.ts` 的状态机段追加：
   ```ts
   check("complete 不能从 not_started 跳完成", nextStatus("not_started", "complete") === "not_started");
   check("materials_draft + complete → completed", nextStatus("materials_draft", "complete") === "completed");
   ```
4. `npm run p6:test`、`npm run p10:test`。

### 影响现有测试

现有测试 **没有** `step: "complete"` 请求，**不应红**。新增断言是加法。

### 预估工作量

1.5 小时。

---

## P0-3　状态机：去掉 PPT 冻结特判，并行用 maxStatus 不回退

- **问题编号**：C（程度已修正）、L3、L4、L6
- **问题简述**：清单说「先材料后 PPT 永久锁死」过重——生成/confirm 已写内容。真正要修的是：① freeze 与 `canGeneratePpt` 不一致；② 生成材料后 status 显示吞掉 `ppt_confirmed`；③ 成功文案撒谎。
- **涉及文件**：
  - `src/lib/prepare/state.ts`
  - `src/lib/prepare/persist.ts`（bumpMeta 记 ppt 确认位）
  - `src/lib/prepare/draft.ts`（GenMeta 类型）
  - `src/app/api/lesson-plans/[id]/confirm/route.ts`（step=ppt 写 meta 位）
  - `src/app/prepare/[classId]/lesson-dev-panel.tsx`（文案）
  - `src/app/prepare/[classId]/prepare-wizard.tsx`（并行说明）
  - `scripts/test-p6.ts`

### 改动思路

**不拆双状态字段、不加第八态。** 七态 RANK 保留（兼容所有 `statusRank` 调用）。并行的「已确认 PPT」用 `generation_meta.ppt_confirmed` 记住，类似已有的 `chaoxing_five_steps`。

#### 1) `nextStatus`：冻结改为 maxStatus

`state.ts` L64–71 换成：

```ts
case "generate_ppt":
  if (statusRank(cur) < RANK.plan_confirmed) return cur;
  return maxStatus(cur, "ppt_draft");
case "confirm_ppt":
  if (statusRank(cur) < RANK.ppt_draft) return cur;
  return maxStatus(cur, "ppt_confirmed");
```

行为对照：

| 当前 | 事件 | 旧 | 新 |
|---|---|---|---|
| plan_confirmed | generate_ppt | ppt_draft | ppt_draft |
| ppt_draft | confirm_ppt | ppt_confirmed | ppt_confirmed |
| materials_draft | generate_ppt | materials_draft（冻） | materials_draft（maxStatus） |
| materials_draft | confirm_ppt | materials_draft（冻） | materials_draft（maxStatus） |
| completed | * | completed | completed |

**status 结果与旧 freeze 相同**，但语义变成「不回退」而不是「拒绝 PPT」。内容路径本来就能写 PPT，这一步主要是去掉误导分支，让 `canGeneratePpt` 与 `nextStatus` 一致。

`test-p6.ts` L208–211「并行 PPT 不回退材料态」**仍然成立**，不要删，可改名为同一句。

#### 2) GenMeta 增加 ppt 确认位

`src/lib/prepare/draft.ts` `GenMeta`：

```ts
ppt_confirmed?: boolean;
ppt_confirmed_by?: number | null;
ppt_confirmed_at?: string | null;
```

`persist.ts` 新增（或扩展 bump）：

```ts
export function bumpMetaOnConfirm(
  raw: string | null | undefined,
  userId: number,
  step?: string
): GenMeta {
  const meta = parseGenMeta(raw);
  meta.confirmed_by = userId;
  meta.confirmed_at = new Date().toISOString();
  if (step === "ppt") {
    meta.ppt_confirmed = true;
    meta.ppt_confirmed_by = userId;
    meta.ppt_confirmed_at = meta.confirmed_at;
  }
  return meta;
}
```

`confirm/route.ts` L117 改为 `bumpMetaOnConfirm(..., auth.user.id, step)`。

（P1-I 再拆 plan/materials 确认人；本 P0 只加 ppt 位，避免一次改太多导出语义。）

#### 3) UI 文案

`confirmPpt` 成功（L383）：

```ts
const st = res.plan.status;
setInfo(
  st === "ppt_confirmed"
    ? "PPT 已确认（ppt_confirmed）。导出将基于 confirmed_content。"
    : "PPT 已写入确认稿。当前课时状态仍为「" + st + "」（材料/完成优先显示，PPT 确认不回退）。"
);
```

材料页 L859、向导 L778 改为明确句：

> 教案确认后可并行生成 PPT 与材料。生成材料后状态显示为材料草稿/已完成，仍可补做并确认 PPT；导出 PPT 看确认稿是否含 slides，不看状态名。

PPT 按钮保持可点（已是）。不要加「须先删材料」。

#### 4) 不要做的

- 不要把 status 改成数组/JSON。
- 不要加「删除材料草稿」才能做 PPT（P2 才考虑纠错清空）。
- 不要让 `confirm_ppt` 从 `materials_draft` 回写成 `ppt_confirmed`（会丢「材料已备」，破坏 A1/待审）。

### 是否需要数据库迁移

**否。** 只写 `generation_meta` JSON。

### 验证方式

1. `npm run p6:test`（必做，状态机断言）。
2. `npm run build`。
3. 手动两条路径：
   - **先 PPT 后材料**：确认教案 → 生成/确认 PPT（status=`ppt_confirmed`）→ 生成材料（status=`materials_draft`）→ 确认材料（`completed`）。导出 PPT 仍成功。
   - **先材料后 PPT**：确认教案 → 生成材料（`materials_draft`）→ 生成 PPT（status 仍 `materials_draft`，但有 ppt）→ 确认 PPT（status 仍 `materials_draft`，导出 PPT 成功）→ 确认材料 → `completed`。
4. 回归：未确认教案不能生成 PPT/材料。

### 影响现有测试

- `test-p6.ts` L209–211 **继续通过**（返回值不变）。
- `test-p5.ts` 只查 `isLessonPlanStatus("ppt_confirmed")`，无影响。
- 无测试检查 freeze 分支本身。

### 预估工作量

2.5 小时。

---

## P0-4　「完成备课」≠「已上课」：显式上课标记

- **问题编号**：B（主）、L8
- **问题简述**：`TAUGHT_PLAN_STATUSES` 把 `completed`/`materials_draft` 当已上课。A1 修好后会更糟（完成即催反馈）。
- **涉及文件**：
  - `src/lib/prepare/draft.ts`（GenMeta.taught_at）
  - `src/lib/feedbacks/types.ts`
  - `src/lib/feedbacks/pending.ts`
  - `src/lib/feedbacks/board.ts`
  - `src/lib/feedbacks/term-report.ts`（读 taught 的那一行）
  - 新 API：`src/app/api/lesson-plans/[id]/taught/route.ts`（仿 chaoxing-steps）
  - `src/app/prepare/[classId]/lesson-dev-panel.tsx` 反馈区
  - `src/app/classes/[id]/feedback-panel.tsx` 文案
  - `scripts/test-s3a.ts`

### 改动思路（无迁移）

与五步勾选同一套路：上课时间进 `generation_meta.taught_at`，**不加表字段**（避免 migrate）。备课七态含义保持「备课进度」。

#### 1) 类型

`draft.ts` GenMeta：

```ts
taught_at?: string | null;
taught_by?: number | null;
```

`feedbacks/types.ts` 替换 L27–32：

```ts
/** @deprecated 备课状态不再代表已上课。请用 isTaughtPlan。 */
export const TAUGHT_PLAN_STATUSES = ["completed", "materials_draft"] as const;

export function isTaughtPlanStatus(_status: string): boolean {
  return false; // 切断旧口径；过渡期保留导出名以免漏改编译
}

export function isTaughtPlan(plan: {
  status?: string;
  generationMeta?: string | null;
  taught_at?: string | null;
}): boolean {
  if (plan.taught_at) return true;
  const meta = plan.generationMeta
    ? (JSON.parse(plan.generationMeta) as { taught_at?: string | null })
    : null;
  // 实际应复用 parseGenMeta，避免裸 JSON.parse
  return Boolean(meta?.taught_at);
}
```

**不要**在业务里继续用 `isTaughtPlanStatus(status)`。更干净的写法：把 `isTaughtPlan` 放到 `feedbacks/types.ts`，内部 `import { parseGenMeta } from "@/lib/prepare/draft"`（注意 draft 是否反向依赖 feedbacks——当前没有，可安全 import）。

推荐最终形态：

```ts
import { parseGenMeta } from "@/lib/prepare/draft";

export function isTaughtPlan(plan: {
  generationMeta?: string | null;
}): boolean {
  return Boolean(parseGenMeta(plan.generationMeta).taught_at);
}
```

删除（或停止调用）`isTaughtPlanStatus`。若担心编译面，保留函数但改为：

```ts
export function isTaughtPlanStatus(_status: string): boolean {
  throw new Error("isTaughtPlanStatus 已废弃，改用 isTaughtPlan(generationMeta)");
}
```

单测会立刻红，正好逼改 `test-s3a.ts`。

#### 2) pending / board / term-report

`pending.ts` `countPendingForPlans` 签名改为接收 `generationMeta`：

```ts
export function countPendingForPlans(
  plans: { classId: number; lessonId: number; status: string; generationMeta?: string | null }[],
  feedbackLessonIds: Set<string>
): number {
  let n = 0;
  for (const p of plans) {
    if (!isTaughtPlan(p)) continue;
    if (!feedbackLessonIds.has(`${p.classId}:${p.lessonId}`)) n += 1;
  }
  return n;
}
```

`loadPendingFeedbackCounts` 的 `lessonPlans select` 加上 `generationMeta: true`。

`board.ts`：组装 item 时 `needs_feedback: isTaughtPlan(plan) && !has`。

`term-report.ts` L160 同样改。

`quality-view.ts` 里 `countPendingForPlans` 调用处把 `generationMeta` 带上（该文件已 `parseGenMeta`，顺手）。

#### 3) API `PUT /api/lesson-plans/[id]/taught`

新建 `src/app/api/lesson-plans/[id]/taught/route.ts`，整体抄 `chaoxing-steps/route.ts`：

```ts
export async function PUT(request, { params }) {
  const auth = await requireApiUser();
  if (auth.error) return auth.error;
  const loaded = await loadOwnedPlan(prisma, Number(params.id), auth.user);
  if (loaded.error) return loaded.error;
  const body = await readJsonBody<{ taught?: boolean; taught_at?: string }>(request);
  const meta = parseGenMeta(loaded.row.generationMeta);
  if (body?.taught === false) {
    meta.taught_at = null;
    meta.taught_by = null;
  } else {
    meta.taught_at = body?.taught_at || new Date().toISOString();
    meta.taught_by = auth.user.id;
  }
  await prisma.lessonPlan.update({
    where: { id: loaded.row.id },
    data: { generationMeta: toJson(meta) },
  });
  return NextResponse.json({ taught_at: meta.taught_at });
}
```

门槛（最小）：不要求 `completed`（教师可能先上课后补确认材料）。建议要求至少 `statusRank >= plan_confirmed`，避免空教案被标已上：

```ts
import { statusRank } from "@/lib/prepare/state";
if (statusRank(loaded.row.status) < 2 /* plan_confirmed */) {
  return NextResponse.json({ error: "须至少确认教案后再标记已上课" }, { status: 400 });
}
```

**不要**在 `confirm_materials` 里自动写 `taught_at`。

#### 4) UI

`lesson-dev-panel.tsx` 反馈区（L893 起）顶部加：

```tsx
<Button
  type="button"
  size="sm"
  variant="outline"
  disabled={busy || !planId || statusRank(bundle?.status ?? "") < 2}
  onClick={() => void markTaught()}
>
  {bundle?.generationMeta?.taught_at ? "已标记上课（再次点击可改时间）" : "标记本课已上完"}
</Button>
<p className="text-xs text-muted-foreground">
  「完成备课」只表示材料已确认；待反馈统计只计算已标记上课且尚未写反馈的课时。
</p>
```

`markTaught` 调上述 API 后刷新 bundle（GET plan 或本地 patch `generationMeta.taught_at`）。

`feedback-panel.tsx` L91 文案改为：「待反馈课时 {pending}（已标记上课且尚未反馈）」。

反馈表单本身保持可填（补记历史课），不清空。

### 是否需要数据库迁移

**否。** 若后续要按日期 SQL 过滤再加 `LessonPlan.taughtAt DateTime?`（那时才 migrate）。当前班级规模用 JSON 足够。

### 验证方式

1. 改 `scripts/test-s3a.ts` L137–149：
   ```ts
   check("无 taught_at 不算已上课", !isTaughtPlan({ generationMeta: null }));
   check("有 taught_at 算已上课", isTaughtPlan({ generationMeta: JSON.stringify({ taught_at: "2026-08-01" }) }));
   const pending = countPendingForPlans(
     [
       { classId: 1, lessonId: 1, status: "completed", generationMeta: JSON.stringify({ taught_at: "x" }) },
       { classId: 1, lessonId: 2, status: "completed", generationMeta: null },
       { classId: 1, lessonId: 3, status: "plan_draft", generationMeta: null },
       { classId: 1, lessonId: 4, status: "materials_draft", generationMeta: null },
     ],
     new Set(["1:1"])
   );
   check("待反馈=已上课且无记录", pending === 0); // 1:1 已有反馈；其余无 taught_at
   ```
   再补一条 lessonId:5 `materials_draft` + taught_at、无反馈 → pending=1。
2. `npm run s3a:test`
3. `npm run build`
4. 手动：
   - 只生成材料 → 工作台待反馈 **不增加**。
   - 确认材料 completed → 待反馈仍不增加。
   - 点「标记本课已上完」→ 待反馈 +1 → 写反馈后归零。
   - 未确认教案不能标记。

### 影响现有测试

**会红，必须改：**

- `scripts/test-s3a.ts` L138–149（把备课状态当已上课）。
- `scripts/demo-s3a.ts` 注释/准备数据若依赖「暂置 completed 以便待反馈计数」（约 L98–131、L285），改为写 `generationMeta.taught_at`。

`quality-view` 若有测试按旧 pending 数，需同步。`grep isTaughtPlanStatus` 全仓改完即可。

### 预估工作量

3.5 小时。

---

## P0 完成后的强制验证清单

每次改完 P0 任一项涉及 `src/app/**` 或 `src/lib/prepare/state.ts` 后：

```bash
npm run p6:test
npm run p10:test
npm run s3a:test
npm run build
```

四项全做完再跑一遍（build 一次即可）。P0-3 主要碰 p6；P0-4 主要碰 s3a；P0-1/P0-2 主要靠手动 + build。

---

## P1（建议修，不挡主链路）

### P1-D　生成上下文限定前序课时（D / J.4）

- **涉及文件**：`src/lib/feedbacks/summarize.ts`、`src/lib/ai/generate.ts` L303、`src/lib/ai/class-gen.ts` L71
- **改动**：给 `loadRecentClassFeedbacks(db, classId, n, { beforeLessonId })` 增加可选过滤：复用 `findPreviousLessonFeedback` 的章/课 sort 比较，丢掉 `chapter.sortOrder/lesson.sortOrder` 不早于当前课的反馈。大纲生成（无 lessonId）仍用全局最近 N 条。
- **迁移**：否
- **测试**：`s3a:test` 若用「后课反馈出现在前课上下文」会需要新断言；现有「按课时排序」断言（test-s3a L132）仍可过。
- **build**：建议
- **估时**：2h
- **影响测试**：可能，属加法

### P1-G　复制 `main_case_id` 两条路径对齐（G）

- **涉及文件**：`src/lib/prepare/copy.ts`
- **改动**：`skeletonDraft` 返回里显式 `next.main_case_id = null`（或 `delete`）。复用分支 merge 之后再：
  ```ts
  const merged = mergeDraftContent(existing.draftContent, skeleton);
  const obj = parseDraftObject(merged);
  delete (obj as { main_case_id?: unknown }).main_case_id;
  ```
  对 `toJson` 写回。画像仍原样复制，只在复制成功 toast 写「请按 B 班修订画像后再生成」。
- **迁移**：否
- **测试**：`test-p6.ts` 已有「复制不带主案例」；补一条 merge 路径（existing 含 main_case_id=9，merge 后为 null）。
- **build**：否
- **估时**：1h

### P1-J5　extract-drafts GET 隔离（J.5）

- **涉及文件**：`src/app/api/extract-drafts/route.ts`
- **改动**：`if (auth.user.role !== "admin") where.createdBy = auth.user.id`。
- **迁移**：否
- **测试**：对应 s2 脚本若用非 admin 列别人草稿会红，按新口径改。
- **build**：是
- **估时**：1h

### P1-J7　RSS fallback 默认关（J.7）

- **涉及文件**：`src/lib/rss/fetch-feed.ts` L52、`src/lib/rss/ingest.ts` L42、`scripts/fetch-rss.ts` L56、`src/app/api/rss-sources/[id]/fetch/route.ts`
- **改动**：`allowFallback = opts?.allowFallback === true`（默认 false）。`demo-s2b.ts` 已显式 `allowFallback: true`，保持。API 文档：生产不要传 true。
- **迁移**：否
- **测试**：`s2b:test` / `demo-s2b` 应仍过
- **build**：否
- **估时**：0.5h

### P1-H　画像写回乐观锁 + 口径（H）

- **涉及文件**：`writeback.ts`、`classes/[id]/route.ts`、`confirm-profile/route.ts`、`revise/route.ts`
- **改动**：PUT/写回 body 收 `expected_version`；`current.version !== expected` → 409。metrics 策略保持「问卷覆盖分布、保留 teacher_notes」，在写回 API 响应里加 `teacher_notes_kept: true, metrics_replaced: true`。UI 确认写回前一句「将用问卷分布替换当前 metrics」。
- **迁移**：否（version 已在 profile JSON）
- **测试**：`p8:test` 写回用例
- **build**：是
- **估时**：3h

### P1-I　确认人拆分（I）

- **涉及文件**：`persist.ts` bumpMeta、`exporters/run.ts` L173–180、`plan-docx.ts` / `ppt.ts` / `materials-docx.ts`
- **改动**：meta 增加 `plan_confirmed_by`；step=plan 时写它。导出走 `plan_confirmed_by ?? confirmed_by`。不改旧数据。
- **迁移**：否
- **测试**：若导出脚本断言确认人，改为教案确认者
- **build**：是
- **估时**：1.5h

### P1-E　文档与 schema 注释对齐（E）

- **涉及文件**：`prisma/schema.prisma` L504、L577（F 可一并）、设计说明书 §5.1（在 dl-hub，不在本仓则只改 schema 注释）
- **改动**：L504 改为与 `state.ts` 头注释一致的并行描述；注明 completed 不强制 PPT。
- **迁移**：否（只改注释，不必 prisma migrate）
- **build**：否
- **估时**：1h

---

## P2（可选）

### P2-F　取消周进度反写承诺（F）

删 schema L577「课时完成后反写周进度」一句，说明书同步。**不实现反写**（规则未定义，半套更糟）。估时 0.5h。无迁移、无测试、无 build。

### P2-J3　补丁 `validUntil`（J.3）

仅当产品确认要自动过期再做：schema 加 `validUntil DateTime?`，`active.ts` where 加 `OR: [{ validUntil: null }, { validUntil: { gt: now } }]`。**需要 `npx prisma migrate dev`**。估时 3h。`npm run build`。

### P2-J6 / P2-I-reg / P2-H-ui / P2-K

说明书写清：样例重发覆盖、regenerate 路由前端未接（以 generate+failed_items 为准）、第一课画像待问卷、母版套用不承诺。均不改行为。合计 ~1.5h。

---

## 明确不改（避免「整改」变成重构）

1. 不把七态拆成 `pptStatus` + `materialsStatus` 两列。
2. 不把 completed 改成「已上课」。
3. 不自动用反馈重写已确认教案。
4. 不强制 completed 必须有 PPT（与并行设计一致；导出自己挡）。
5. 不实现周进度反写算法。
6. 不改 G2「只在生成拦、确认不重查」。
7. 不把学时超载改成硬 400。

---

## 推荐实施顺序

```
P0-2（封 complete，1.5h）
  → P0-3（maxStatus + 文案，2.5h，p6:test）
  → P0-1 + P0-4 同一提交（按钮 + 上课标记，6h，s3a:test + 手动）
  → npm run build
  → P1-G / P1-J7 / P1-D / P1-J5（半到一天）
  → 其余 P1/P2 按需
```

P0-2 先做是因为不依赖 UI，且先堵 API 洞再给教师按钮，避免有人在联调窗口打 complete。
