17 KiB
dealerhub · 批次 B + C 实施报告(AI 差异化 + 商业化闭环)
依据:《后续改造实施计划.md》批次 B(P1 AI 差异化 #7–9)与批次 C(P2 商业化基建 #10–13)。 结果:两批次全部落地。全量回归 268 passed(批次 A 后 160 → 新增 108 例),
manage.py check0 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/<id>/(三因子明细)、/ai/risk/advice/<id>/(催收建议)、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}
真实拦截点(不是纸面):
BaseTenantViewSet.acreate读quota_kind声明 →ProductViewSet.quota_kind = "products"- 销售/采购
create-bill入口 →bills_monthly - AI 两个入口 →
ai_parse_order/ai_ask - 前端 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 请求 → 403demo_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: <JWT>}
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/ [重建]
踩坑记录(供后续参考)
- 异常类跨模块复用:
billing.QuotaExceeded与ai.QuotaExceeded必须能被视图层一处 catch。做法是 ai 侧继承 billing 的类;但父类构造签名不同,__init__里要直接调Exception.__init__,否则 TypeError(本轮踩到)。 - 服务层自生成单号 vs 幂等:
create_sales_bill/create_purchase_bill自己生成单号,演示数据要固定单号只能创建后update(bill_no=...)回写。若后续有更多幂等需求,建议给服务层加可选bill_no参数。 - Django 模型发现约定:模型必须定义在
models.py(或models/包)里才会被makemigrations发现;放在usage.py需要加一个models.py转发。另外 app 目录必须有migrations/包,否则makemigrations静默报 "No changes detected"。 - DRF
url_path唯一:同一路径不能挂两个 action(后者覆盖前者,前者变 405)。同路径多方法要合并成一个 action 按request.method分派。 - DRF 保留参数
format:?format=会走内容协商,找不到对应 renderer 直接 404。自定义输出格式请换参数名(本轮用?view=html)。 - adrf async 视图:惰性 FK 访问与
refresh_from_db()都必须包在sync_to_async里,否则SynchronousOnlyOperation。 - Granian on Windows:不支持多 worker(自动回落 1);改动后重启务必
netstat确认端口只有一个 PID,否则旧实例抢答会让"改了没生效"。 - 演示数据要留额度余量:
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,按客户驱动)
- D1 B2B 订货商城 H5(
apps/storefront/):客户授权可见商品 + 等级价复用quote_price+ 自助下单生成草稿 + 接 C1 配额(storefront开关已就位) - D2 业务员移动开单:H5 复用 D1 框架 + A1 取价/单位/抹零组件
- D3 行业专版:序列号 SN(3C)/ 辅助属性(服装)/ 套装拆件(建材)
- D4 税率:
SalesBillLine.tax_rate+ 价内价外口径 + 凭证科目拆分(多币种建议从话术删除,清单问题 7 要求) - 打印扩展:三联送货单 / 58mm 小票 / 对账单二维码印在销售单上(串起 A3 分享链路)
注意:D 系列启动前建议先把线上(192.168.5.7)镜像重建,让批次 A/B/C 的成果先上线,再按客户反馈决定 D 的优先级。