# dealerhub · 批次 B + C 实施报告(AI 差异化 + 商业化闭环) > **依据**:《后续改造实施计划.md》批次 B(P1 AI 差异化 #7–9)与批次 C(P2 商业化基建 #10–13)。 > **结果**:两批次全部落地。全量回归 **268 passed**(批次 A 后 160 → 新增 108 例), > `manage.py check` 0 issues,`makemigrations --check` 无待生成迁移,`npm run build` 通过, > Playwright 真实浏览器逐项验证。**最小可售闭环达成**(P0 引擎 + 前端可见 + 套餐计费 + 官网 + 演示账套)。 --- ## 批次 B · AI 差异化(新增 `apps/ai/`) ### B1 · AI 应收风险预警 ★ 主打差异化 **对外话术**:唯一做应收风控的进销存——竞品 AI 全在开单/问答,没人做应收风险。 **三因子加权评分(`apps/ai/risk.py`,纯统计,不依赖 LLM)** | 因子 | 权重 | 算法 | |---|---|---| | 回款周期漂移 | 40% | 近 90 天平均回款天数 vs 历史基线(近一年、排除近期样本);比值 ≤1 不计分,1.0→1.5 线性映射到 0→100 | | 欠款趋势 | 35% | 本月未结 vs 上月同口径;增长 0→50% 线性映射;上月无欠款本月有 → 50 分(新增欠款) | | 开单频次骤降 | 25% | 有欠款但 >30 天未开单;30 天 0 分,90 天 100 分,中间线性 | **分档**:high ≥70 / medium 40–69 / low <40。**回款周期用收款核销(Allocation)的最晚核销日 − 应收开单日**近似,未结清的不参与该因子(走欠款趋势因子)。 **落点**: - `notify` 新增 `risk_score` 规则类型(threshold = 分数线,默认 70),`check_risk_score_alerts` 按(客户+当日)去重发预警,挂入 `run_all_alert_checks` - 默认预警规则新增 `ai_risk_score`(阈值 70) - API:`GET /ai/risk/ranking/`(TOP N)、`/ai/risk/customer//`(三因子明细)、`/ai/risk/advice//`(催收建议)、`POST /ai/risk/scan/` - 前端:大盘"应收风险 TOP5"卡片(风险进度条 + 档位 + 未结 + 回款周期/欠款趋势两列理由 + 催收建议弹窗) **LLM 只做增值**:有 KEY 时把统计理由交给 LLM 生成一句人话建议;无 KEY 时 `advice` 直接返回统计理由,`llm_enhanced=false`(不 500、不空转)。 ### B2 · AI 开单(`apps/ai/orders.py`) 链路:`粘贴文本 → LLM 抽取 [{name,barcode,qty,unit}] → 匹配商品档案 → 前端确认 → 回填开单表` **匹配优先级**:`barcode 精确(100) → 商品编码精确(100) → 品名精确(100) → 品名包含(85,取最短匹配名) → 相似度兜底(difflib ≥0.6)`;未命中的进 `unmatched` 单独返回。 **单位与取价**:抽取到的单位名映射到 `UnitConversion`,命中则返回该单位的 `unit_id/unit_name/price`(价 = 基本价 × rate),前端复用 A1 的取价与最低售价逻辑。 **无 KEY 行为**(验收要求):默认 **抛 `LlmUnavailable` → 400 + `code=llm_unavailable`**(明确报错,绝不 500);同时提供 `allow_rule_fallback=true` 走**规则兜底**(正则抓"商品名+数量+单位",支持中文数字"两箱/十瓶")——前端在 400 后弹确认框,用户可选择用兜底模式。 **配额**(`apps/ai/usage.py`):`AiUsage` 流水(租户+kind+账期),`check_quota` 超限抛 `QuotaExceeded` → **403 + `code=quota_exceeded` + 升级引导**。配额来源优先走 billing(见 C1),未装 billing 时回落 `settings.AI_FREE_MONTHLY_QUOTA`。 ### B3 · AI 经营问答(`apps/ai/ask.py`) **安全设计(计划硬要求:只读、不给 LLM 写权限)**:取数完全由**白名单工具**完成,LLM 只拿到聚合后的数字: - 意图识别:`sales / receivable / inventory / purchase / profit / ranking / risk`,关键词命中;都没命中给"全景四项" - 7 个工具全部只读 ORM 且强制 `tenant=tenant`(越权查他租户不可能,有专项测试) - 无 LLM 时返回**统计口径结构化回答**(`llm_enhanced=false`),功能不空转 - 前端:大盘"🤖 老板参谋"入口 + 5 个预设问题 + 回答区(标注"AI 生成"或"统计口径") --- ## 批次 C · 商业化基建 ### C1 · 套餐/试用/配额(新增 `apps/billing/`) **套餐定义(`Plan.limits` 是唯一事实来源)** | | 免费版 | 基础版 | 专业版 | |---|---|---|---| | 价格 | ¥0 | **¥998/月** | **¥4800/月** | | 用户 / 商品 / 月单据 | 1 / 100 / 50 | 5 / 3000 / 2000 | 50 / 100000 / 不限 | | AI 录单 / 问答 | 10 / 20 | 200 / 500 | 2000 / 5000 | | 批次效期 / 总账 | ✓ / ✓ | ✓ / ✓ | ✓ / ✓ | | 订货商城 | ✗ | ✗ | ✓ | > 定价依据(清单 #10):卡在金蝶 698 与用友 1500 之间偏上,凭"带总账 + AI 风控"打差异。 **订阅(`Subscription`)**:`trial(30 天全功能) / active / expired`;`effective_limits()` 在试用过期或已过期时**回落 free 配额**(数据保留)。 **配额校验统一入口**:`billing.quota.check_and_count(tenant, kind, delta)` - 数量型:`users` / `products` / `bills_monthly` - 功能开关:`batch_managed` / `finance_ledger` / `storefront` - AI 次数:委托 `apps.ai.usage.check_quota`(单一实现,billing 只提供配额数字) - 超限 → `QuotaExceeded.as_dict()`:`{code, kind, detail, used, limit, plan_code, plan_name, upgrade_url}` **真实拦截点(不是纸面)**: 1. `BaseTenantViewSet.acreate` 读 `quota_kind` 声明 → `ProductViewSet.quota_kind = "products"` 2. 销售/采购 `create-bill` 入口 → `bills_monthly` 3. AI 两个入口 → `ai_parse_order` / `ai_ask` 4. 前端 axios 拦截器 → 403 `quota_exceeded` 弹**升级引导弹窗**(列差距 + 跳套餐页,同 kind 60 秒内不重复弹) **试用到期**:`billing.tasks.expire_trials()`(Dramatiq cron 每日)扫描试用与付费到期 → 降级 free + 发通知。 **API**:`GET /billing/plans/`(公开,官网价格页用)、`/billing/subscription/`(用量快照)、`POST /billing/subscribe/`、`GET /billing/quota/?kind=`。 **前端**:新页"套餐与用量"(当前套餐 + 试用倒计时 + 各配额进度条 + 功能开关 + 三档对比卡片一键切换)。 ### C2 · 演示账套(`manage.py seed_demo`) **食品经销商场景**,突出已交付能力: - **5 个商品**:多单位(瓶/箱/件、箱/提)、批次管理(鲜奶 21 天/优倍 15 天保质期)、称重(kg 基本单位)、最低售价 - **5 个客户**:分层信用额度(3 万–8 万),含一个**风险户 C1005** - **3 张进货单**:含两个批次(近效期 10 天 / 正常 14 天)用于演示临期预警 - **6 张销售单**:整件/散包混合录入 + 抹零(元/角两档)+ 批次商品 FEFO 跨批分摊 + 风险户大额赊销 - **5 笔历史应收**:覆盖账龄 **0-30 / 31-60 / 61-90 / 91-180 / 181-365** 五桶(账龄图不是一根柱) - **幂等**:所有对象按 code/bill_no `update_or_create`;`--reset` 清空重建 **免注册体验**: - `POST /api/v1/demo/enter/`(公开)→ 签发 2 小时 JWT + 返回只读提示;未初始化返回 503 + 明确指引 - **只读保护由中间件强制**(`DemoReadOnlyMiddleware`):租户 `demo` 的一切非 GET 请求 → 403 `demo_read_only`,**不依赖前端自觉**(手工 curl 也拦得住,有专项测试) - 前端登录页"🎬 免注册体验演示账套"按钮 + 侧栏"只读演示"徽标 + 403 明确提示 ### C3 · 营销官网(新增 `apps/website/`,`/site/*`) | 页面 | 内容 | SEO | |---|---|---| | `/site/` 首页 | H1"AI 开单 + 应收风控的经销商进销存"、三大 AI 卖点(含"独家"标注)、6 项基础能力、**6 角色价值区块**、价格预览、演示入口 | title/desc/OG/canonical/JSON-LD(SoftwareApplication) | | `/site/pricing/` | 三档卡片 + **功能对比矩阵**(数据源 = `Plan.limits`,与配额校验同源)+ 三条说明 | 同上 | | `/site/industry/food/` | 食品:批次账 + FEFO + 临期预警 + 多单位 + 信用额度(**均为已交付**) | 行业关键词 | | `/site/industry/hardware/` | 五金:换算率 + 三档抹零 + 最低售价 + 价格历史参考 | 同上 | | `/site/industry/autoparts/` | 汽配:当前可用能力 + **SN 追溯明确标注"规划中/尚未交付"** | 同上 | **"不写纸面功能"的执行**:价格页每个勾选对应已交付代码;行业页每条卖点映射到一个已有测试;未交付的(订货商城在专业版、汽配 SN)标注规划中。测试 `test_autoparts_page_marks_unshipped_as_planned` 专门守这条。 **移动端**:全站响应式(860px 断点),viewport meta,单文件内联 CSS(无外部依赖,加载快)。 --- ## 验证汇总 ### 1. 后端回归 ``` $ python -m pytest tests/ -p no:cacheprovider 268 passed in 5.06s # 批次 A 后 160 → +108(B1 23 + B2/B3 25 + C1 25 + C2 14 + C3 21) $ python manage.py check System check identified no issues (0 silenced). $ python manage.py makemigrations --check --dry-run No changes detected ``` ### 2. `npm run build` ``` ✓ built in 5.84s dist/assets/Dashboard-*.js 5.41 kB ← AI 风险卡 + 老板参谋 dist/assets/SalesBills-*.js 15.95 kB ← +AI 录单对话框 dist/assets/Billing-*.js ~6 kB ← 套餐与用量页 ``` ### 3. Playwright 真实浏览器(Granian ASGI) | 场景 | 结果 | |---|---| | 大盘"应收风险 TOP5" | 王五批发 95 分/高风险/¥2,300,三因子理由分列(回款 45 天 vs 5 天、欠款 +53%、90 天未开单) | | 催收建议弹窗 | 无 KEY → 显示统计口径理由,`llm_enhanced=false` | | 老板参谋 | 预设"谁欠钱最多"→ 回答"本月销售额 ¥1,723.00(3 单);应收未结 ¥4,023.00,其中 30 天以上 ¥1,500.00;库存估值 ¥1,180.06;本月净利润 ¥1,182.27",标注"统计口径"、取数 4 项 | | 免注册体验 | 登录页按钮 → 直进演示账套;侧栏显示"只读演示"徽标 | | 演示只读保护 | 读接口 200(大盘/销售单/账龄/AI 风险),写接口 403 `demo_read_only` | | 套餐与用量页 | 当前套餐"免费版/试用中"、试用期至 2026-10-10、商品 5/100 进度条、AI 录单 0/10、功能开关 ✓批次效期 ✓总账 ✗订货商城 | | 官网首页 | H1 + 三大 AI 卖点(独家/省时/参谋)+ 6 能力卡 + 6 角色 + 价格预览 + CTA | | 官网价格页 | 三档卡片 + 对比矩阵(1/5/50 用户、100/3,000/100,000 商品、专业版"不限"、订货商城 ✓/— 正确) | ### 4. 关键接口手测 ``` POST /api/v1/demo/enter/ → 200 {tenant: demo, read_only: true, access: } POST /api/v1/catalog/products/ (demo) → 403 {code: demo_read_only} GET /api/v1/ai/risk/ranking/ → 95 分客户 + 三因子理由 POST /api/v1/ai/ask/ → intents: [sales, receivable, inventory, profit] POST /api/v1/ai/parse-order/ (无KEY) → 400 {code: llm_unavailable} GET /api/v1/billing/plans/ → free/basic/pro(998 / 4800) GET /api/v1/billing/quota/?kind=products → 403 + upgrade_url(满额时) GET /site/pricing/ → 200 14KB(对比矩阵与 Plan.limits 同源) ``` --- ## 变更文件清单 ``` backend/ ├── apps/ai/ [新] AI 应用 │ ├── risk.py 三因子应收风险评分(纯统计) │ ├── orders.py 文本抽取 + 商品匹配 + 规则兜底 │ ├── ask.py 白名单只读工具 + 统计口径回答 │ ├── llm.py OpenAI 兼容客户端(无 KEY 全降级) │ ├── usage.py AiUsage 流水 + 配额(异常类与 billing 统一) │ ├── views.py·urls.py 7 个端点 │ └── models.py (转发 usage.AiUsage,满足 Django 发现约定) ├── apps/billing/ [新] 套餐与计费 │ ├── models.py Plan / Subscription + 三档预置 + 试用/升级 │ ├── quota.py check_and_count 统一入口 + 用量快照 │ ├── tasks.py 试用/订阅到期降级 + 通知 │ └── views.py·urls.py 4 个端点 ├── apps/website/ [新] 营销官网 │ ├── views.py 首页/价格页/3 行业页(价格读 Plan.limits) │ └── templates/website/ base / home / pricing / industry(全响应式 + SEO) ├── apps/core/demo.py [新] 免注册进入演示账套 ├── apps/core/middleware.py [改] +DemoReadOnlyMiddleware ├── apps/core/viewset.py [改] acreate 配额拦截(quota_kind) ├── apps/core/management/commands/seed_demo.py [新] 幂等演示账套 ├── apps/catalog/views.py [改] ProductViewSet.quota_kind ├── apps/sales/views.py·purchase/views.py [改] 建单入口配额校验 ├── apps/notify/models.py·services.py [改] risk_score 规则 + 扫描 + 默认规则 ├── config/settings/base.py [改] +apps.ai/billing/website + AI 配置 ├── config/urls.py [改] +/site/ 与 /demo/enter/ ├── apps/*/migrations/ [新] ai / billing └── tests/test_ai_risk.py·test_ai_order_ask.py·test_billing.py·test_demo.py·test_website.py [新] 108 例 frontend/src/ ├── pages/Billing.vue [新] 套餐与用量(用量进度 + 三档对比) ├── pages/Dashboard.vue [改] +应收风险 TOP5 + 老板参谋对话框 ├── pages/SalesBills.vue [改] +AI 录单对话框(含兜底确认) ├── pages/Login.vue [改] +免注册体验按钮 ├── layouts/MainLayout.vue [改] +只读演示徽标 + 套餐菜单 ├── api/client.js [改] +配额升级引导 + 只读演示 403 提示 └── router/index.js [改] +/billing frontend/dist/ [重建] ``` --- ## 踩坑记录(供后续参考) 1. **异常类跨模块复用**:`billing.QuotaExceeded` 与 `ai.QuotaExceeded` 必须能被视图层一处 catch。做法是 ai 侧继承 billing 的类;但父类构造签名不同,`__init__` 里要直接调 `Exception.__init__`,否则 TypeError(本轮踩到)。 2. **服务层自生成单号 vs 幂等**:`create_sales_bill` / `create_purchase_bill` 自己生成单号,演示数据要固定单号只能创建后 `update(bill_no=...)` 回写。**若后续有更多幂等需求,建议给服务层加可选 `bill_no` 参数**。 3. **Django 模型发现约定**:模型必须定义在 `models.py`(或 `models/` 包)里才会被 `makemigrations` 发现;放在 `usage.py` 需要加一个 `models.py` 转发。另外 app 目录**必须有 `migrations/` 包**,否则 `makemigrations` 静默报 "No changes detected"。 4. **DRF `url_path` 唯一**:同一路径不能挂两个 action(后者覆盖前者,前者变 405)。同路径多方法要合并成一个 action 按 `request.method` 分派。 5. **DRF 保留参数 `format`**:`?format=` 会走内容协商,找不到对应 renderer 直接 404。自定义输出格式请换参数名(本轮用 `?view=html`)。 6. **adrf async 视图**:惰性 FK 访问与 `refresh_from_db()` 都必须包在 `sync_to_async` 里,否则 `SynchronousOnlyOperation`。 7. **Granian on Windows**:不支持多 worker(自动回落 1);改动后重启务必 `netstat` 确认端口只有一个 PID,否则旧实例抢答会让"改了没生效"。 8. **演示数据要留额度余量**:`seed_demo` 的历史应收会占用客户信用额度,幂等重跑时可能被信用引擎拦住——演示客户额度要给足(本轮从 3 万提到 3–8 万)。 --- ## 里程碑对照(计划 §五) | 里程碑 | 计划内容 | 实际 | |---|---|---| | M-A | 批次 A 完成,前端接入全部 P0 能力 | ✅ `PROGRESS_BATCH_A.md`,160 passed | | M-B | 批次 B 完成,三个 AI 卖点可演示(含无 KEY 降级) | ✅ 本报告,B1/B2/B3 全部可演示,降级路径有专项测试 | | M-C | 批次 C 完成,**最小可售闭环达成** | ✅ 官网可访问、价格页公开、演示账套可进、free 版配额真实拦截 | | M-D | 订货商城/移动开单随第一个付费客户上线 | ⏳ 未开始(批次 D,计划中即为"按客户驱动") | **最小可售闭环 = P0 引擎 + 批次 A 前端 + B1 风控 + 批次 C** → **已达成**。 --- ## 下一步(批次 D,按客户驱动) 1. **D1 B2B 订货商城 H5**(`apps/storefront/`):客户授权可见商品 + 等级价复用 `quote_price` + 自助下单生成草稿 + 接 C1 配额(`storefront` 开关已就位) 2. **D2 业务员移动开单**:H5 复用 D1 框架 + A1 取价/单位/抹零组件 3. **D3 行业专版**:序列号 SN(3C)/ 辅助属性(服装)/ 套装拆件(建材) 4. **D4 税率**:`SalesBillLine.tax_rate` + 价内价外口径 + 凭证科目拆分(**多币种建议从话术删除**,清单问题 7 要求) 5. **打印扩展**:三联送货单 / 58mm 小票 / 对账单二维码印在销售单上(串起 A3 分享链路) > 注意:D 系列启动前建议先把**线上(192.168.5.7)镜像重建**,让批次 A/B/C 的成果先上线,再按客户反馈决定 D 的优先级。