Bladeren bron

feat: 零单门店诊断(未开业/闭店/无曝光/无进店/无转化)

- 服务端按平台字段判定零单原因:有效门店+营业时长+异常关店时长+曝光/进店/下单
- dashboard 返回 zeroOrder 与 zeroOrderSummary;AI 上下文携带诊断,提示词要求引用具体原因而非笼统说「零单」
- 驾驶舱 KPI 下方加「零单诊断 N 店次」药丸条,按类型着色
- 测试:未开业识别断言(47/47),冒烟 112/112
liangzhao 2 weken geleden
bovenliggende
commit
f1dcea4390
5 gewijzigde bestanden met toevoegingen van 96 en 2 verwijderingen
  1. 1 1
      README.md
  2. 22 0
      public/cockpit.js
  3. 9 0
      public/style.css
  4. 2 0
      scripts/test-ops-import.js
  5. 62 1
      server/routes/ops.js

+ 1 - 1
README.md

@@ -52,7 +52,7 @@
   - **平台导出全字段明细**:一行一店一天,含 `日期 / 门店编号 / 门店名称` 与 100+ 列指标(营业额、有效订单、曝光、评分等),全部原样入库。
 - **重复校验**:平台明细按「日期 + 门店编号」判断——同一天同一店的相同数据自动跳过;内容有变化记为「冲突」不覆盖,勾选「覆盖已导入的文件」才覆盖。上传结果会显示「新增 N / 已存在 N / 冲突 N」。
 - **报表展示**:一张表、一行一店一天,列 = 店铺 / 日期 / 勾选的指标(勾几个显示几列),按学校分组并带每日小计。平台明细同样进入「店铺运营」报表——有效订单→订单量、营业额→营收、曝光人数→曝光量、进店转化率→进店转化率;同一天同一店两种来源都有时,以人工表为准(人工优先、平台补位)。
-- **经营驾驶舱**:一屏看经营大盘——6 个 KPI(营业额 / 有效订单 / 客单价 / 曝光人数 / 进店转化率 / 店铺评分,带环比)+ 营业额与订单趋势、门店营业额 Top10、转化漏斗(曝光→进店→下单)、收入结构(收入/支出/补贴)。支持日期、学校、店铺筛选,环比按「紧邻的等长区间」计算。
+- **经营驾驶舱**:一屏看经营大盘——6 个 KPI(营业额 / 有效订单 / 客单价 / 曝光人数 / 进店转化率 / 店铺评分,带环比)+ 营业额与订单趋势、门店营业额 Top10、转化漏斗(曝光→进店→下单)、收入结构(收入/支出/补贴)。支持日期、学校、店铺筛选,环比按「紧邻的等长区间」计算;下方「零单诊断」用平台字段区分未开业 / 当日闭店 / 无曝光 / 无进店 / 无转化。
 - **AI 经营解读**:点「生成解读」,把当前范围的 KPI、趋势、门店增减、漏斗与成本结构整理成上下文交给对话模型,输出 3~5 条结论与建议(引用具体数字);结果缓存 10 分钟,可复制。未配置对话模型时会明确提示去「系统设置 → API 设置」配置。
 - **字段与店铺筛选**:字段下拉可勾选平台导出里的任意列(默认 4 个基础指标),勾几个就显示几列;店铺下拉可多选门店,只看选中的店;选择保存在浏览器本地。
 - **学校归属**:平台表里没有学校列时,优先用上传时选的学校,其次从文件名识别(如「浙江工商桐庐校区20261018…」匹配到已登记学校);都匹配不到会要求先选学校。

+ 22 - 0
public/cockpit.js

@@ -99,6 +99,7 @@
         <div class="ckpt-ai-body" id="ckptAiBody">点击「生成解读」,模型会根据当前筛选范围的数据给出结论与建议。</div>
       </section>
       <div class="ckpt-kpis" id="ckptKpis"></div>
+      <div class="ckpt-zero" id="ckptZero" hidden></div>
       <div class="ckpt-grid">
         <section class="ckpt-card"><div class="ckpt-card-head"><h3>营业额 / 订单趋势</h3><span id="ckptTrendNote"></span></div><div class="ckpt-chart" id="ckptTrend"></div></section>
         <section class="ckpt-card"><div class="ckpt-card-head"><h3>门店营业额 Top 10</h3><span></span></div><div class="ckpt-chart" id="ckptRank"></div></section>
@@ -180,6 +181,26 @@
     }).join('');
   }
 
+  function renderZeroOrder(data) {
+    const box = $('ckptZero');
+    if (!box) return;
+    const items = data.zeroOrder || [];
+    if (!items.length) {
+      box.hidden = true;
+      box.innerHTML = '';
+      return;
+    }
+    const chips = items.map((item) => {
+      const tone = item.reason === 'no-conversion' ? 'warn'
+        : item.reason === 'no-visit' || item.reason === 'no-traffic' ? 'info' : 'muted';
+      return `<span class="ckpt-zero-chip ${tone}" title="${esc(item.reasonText)}">
+        <b>${shortDate(item.date)} ${esc(item.shopName)}</b>${esc(item.reasonText)}
+      </span>`;
+    }).join('');
+    box.hidden = false;
+    box.innerHTML = `<span class="ckpt-zero-label">零单诊断 ${items.length} 店次</span>${chips}`;
+  }
+
   function renderTrend(data) {
     const chart = state.charts.trend;
     const dates = data.trend.map((t) => shortDate(t.date));
@@ -357,6 +378,7 @@
         $('ckptFrom').value = ymdToInput(state.from);
         $('ckptTo').value = ymdToInput(state.to);
         renderKpis(body.kpis);
+        renderZeroOrder(body);
         renderTrend(body);
         renderRanking(body);
         renderFunnel(body);

+ 9 - 0
public/style.css

@@ -1380,3 +1380,12 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 .ckpt-ai-list li { margin: 3px 0; }
 .ckpt-ai-loading { color: var(--text-3); }
 .ckpt-ai-error { color: #c0392b; }
+
+/* 驾驶舱 · 零单诊断 */
+.ckpt-zero { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: -2px 0 14px; }
+.ckpt-zero-label { font-size: 11.5px; color: var(--text-3); }
+.ckpt-zero-chip { display: inline-flex; align-items: baseline; gap: 6px; padding: 4px 9px; font-size: 11.5px; color: var(--text-2); background: var(--bg); border: 1px solid var(--border); border-radius: 999px; }
+.ckpt-zero-chip b { font-weight: 600; color: var(--text); }
+.ckpt-zero-chip.warn { border-color: rgba(255, 107, 53, 0.35); background: #fff5ef; }
+.ckpt-zero-chip.info { border-color: #cfe0ef; background: #f3f8fc; }
+.ckpt-zero-chip.muted { color: var(--text-3); }

+ 2 - 0
scripts/test-ops-import.js

@@ -429,6 +429,8 @@ async function main() {
       expect(d.structure.length === d.trend.length, '结构图与趋势日期数不一致');
       expect(d.ranking.length <= 10 && d.ranking.length > 0, `ranking ${d.ranking.length}`);
       expect(d.funnel.exposure >= 0 && d.funnel.visit >= 0, '漏斗数据异常');
+      expect(Array.isArray(d.zeroOrder), '缺少零单诊断');
+      expect(d.zeroOrder.some((z) => z.reason === 'not-open'), '未识别出未开业门店');
     });
 
     await check('E2E 驾驶舱:店铺筛选后金额变化', async () => {

+ 62 - 1
server/routes/ops.js

@@ -267,6 +267,39 @@ function platformNum(row, column) {
   return numOrNull(row.platform[column]);
 }
 
+/** 零单门店诊断:用平台字段区分「未开业 / 闭店 / 无曝光 / 无进店 / 无转化」 */
+function diagnoseZeroOrder(row) {
+  const p = row.platform;
+  const orders = Number(row.order_cnt);
+  if (!(orders === 0)) return null;
+  if (!p) {
+    return { reason: 'unknown', reasonText: '缺少平台明细,无法判断' };
+  }
+  const bizHours = String(p['营业时长'] == null ? '' : p['营业时长']).trim();
+  const zeroBiz = /^0\s*小时\s*0\s*分$/.test(bizHours) || bizHours === '0' || bizHours === '';
+  const abnormal = numOrNull(p['异常关店时长']) || 0;
+  const exposure = numOrNull(p['曝光人数']);
+  const visit = numOrNull(p['进店人数']);
+  const validShop = String(p['是否有效门店'] || '').trim();
+
+  if (zeroBiz) {
+    if (validShop === '否') {
+      return { reason: 'not-open', reasonText: '未开业(非有效门店,当日无营业时长)', bizHours, abnormal, exposure, visit, validShop };
+    }
+    if (abnormal > 0) {
+      return { reason: 'closed', reasonText: `当日闭店(异常关店 ${abnormal} 分钟,无营业时长)`, bizHours, abnormal, exposure, visit, validShop };
+    }
+    return { reason: 'not-open', reasonText: '未营业(当日无营业时长,也未记录异常关店)', bizHours, abnormal, exposure, visit, validShop };
+  }
+  if (exposure === 0) {
+    return { reason: 'no-traffic', reasonText: '营业中但无曝光(未进流量池)', bizHours, abnormal, exposure, visit, validShop };
+  }
+  if (visit === 0) {
+    return { reason: 'no-visit', reasonText: `有曝光 ${exposure} 但无进店`, bizHours, abnormal, exposure, visit, validShop };
+  }
+  return { reason: 'no-conversion', reasonText: `有进店 ${visit} 但零下单(转化问题)`, bizHours, abnormal, exposure, visit, validShop };
+}
+
 /** 一次聚合:KPI 口径集中在这里,前端不再二次计算 */
 function aggregateRows(rows) {
   let revenue = 0;
@@ -396,6 +429,24 @@ async function buildDashboard(store, { from, to, schools, shops }) {
     order: cur.buyers || cur.orders,
   };
 
+  const zeroOrder = [];
+  for (const row of rows) {
+    const diagnosis = diagnoseZeroOrder(row);
+    if (!diagnosis) continue;
+    zeroOrder.push({
+      date: row.date,
+      shopId: row.shopId,
+      shopName: row.shopName,
+      school: row.school || '',
+      orders: 0,
+      ...diagnosis,
+    });
+  }
+  const zeroOrderSummary = zeroOrder.reduce((acc, item) => {
+    acc[item.reason] = (acc[item.reason] || 0) + 1;
+    return acc;
+  }, {});
+
   const structure = [...byDate.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([date, list]) => {
     const agg = aggregateRows(list);
     return { date, income: agg.revenue, expense: agg.expense, subsidy: agg.subsidy };
@@ -409,6 +460,8 @@ async function buildDashboard(store, { from, to, schools, shops }) {
     trend,
     ranking,
     bottom,
+    zeroOrder,
+    zeroOrderSummary,
     shopCount: allShops.length,
     funnel,
     structure,
@@ -422,7 +475,8 @@ const INSIGHT_SYSTEM_PROMPT = [
   '2. 必须引用 JSON 里的具体数字(金额保留两位小数、人数/单量用整数、比率用百分比);',
   '3. 指出风险(下滑、零单门店、成本占比高、漏斗转化偏低等),并给一句可执行建议;',
   '4. 数据不足(如 prev 为 null、只有 1 天)就直接说明,不要编造;',
-  '5. 每条不超过 60 字,不要输出 JSON、标题或前后缀。',
+  '5. 说到零单门店时,必须引用 zeroOrder[].reasonText 的具体原因(未开业/闭店/无曝光/无进店/无转化),不要笼统说「零单门店」;',
+  '6. 每条不超过 60 字,不要输出 JSON、标题或前后缀。',
 ].join('\n');
 
 function insightContext(data) {
@@ -450,6 +504,13 @@ function insightContext(data) {
     shopCount: data.shopCount,
     topShops: (data.ranking || []).slice(0, 5).map((s) => ({ name: s.shopName, revenue: s.revenue, orders: s.orders })),
     bottomShops: (data.bottom || []).map((s) => ({ name: s.shopName, revenue: s.revenue, orders: s.orders })),
+    zeroOrderSummary: data.zeroOrderSummary || {},
+    zeroOrder: (data.zeroOrder || []).slice(0, 20).map((z) => ({
+      date: z.date,
+      shop: z.shopName,
+      reason: z.reason,
+      reasonText: z.reasonText,
+    })),
     structure: (data.structure || []).map((s) => ({
       date: s.date,
       income: s.income,