|
@@ -8,6 +8,8 @@ const { sendJson, readBody } = require('../util.js');
|
|
|
const opsImport = require('../../ops-import.js');
|
|
const opsImport = require('../../ops-import.js');
|
|
|
const { createOpsStore } = require('../../ops-store.js');
|
|
const { createOpsStore } = require('../../ops-store.js');
|
|
|
const { loadSchools } = require('../../schools-store.js');
|
|
const { loadSchools } = require('../../schools-store.js');
|
|
|
|
|
+const { makeChatCfg } = require('../context.js');
|
|
|
|
|
+const { chatCompletion } = require('../agent/llm.js');
|
|
|
|
|
|
|
|
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
|
const UPLOAD_ID_RE = /^\d{8}-\d{6}-[a-z0-9]{6}$/;
|
|
const UPLOAD_ID_RE = /^\d{8}-\d{6}-[a-z0-9]{6}$/;
|
|
@@ -379,13 +381,14 @@ async function buildDashboard(store, { from, to, schools, shops }) {
|
|
|
return { date, revenue: agg.revenue, orders: agg.orders };
|
|
return { date, revenue: agg.revenue, orders: agg.orders };
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- const ranking = [...byShop.values()]
|
|
|
|
|
|
|
+ const allShops = [...byShop.values()]
|
|
|
.map((shop) => {
|
|
.map((shop) => {
|
|
|
const agg = aggregateRows(shop.rows);
|
|
const agg = aggregateRows(shop.rows);
|
|
|
return { shopId: shop.shopId, shopName: shop.shopName, school: shop.school, revenue: agg.revenue, orders: agg.orders };
|
|
return { shopId: shop.shopId, shopName: shop.shopName, school: shop.school, revenue: agg.revenue, orders: agg.orders };
|
|
|
})
|
|
})
|
|
|
- .sort((a, b) => b.revenue - a.revenue)
|
|
|
|
|
- .slice(0, 10);
|
|
|
|
|
|
|
+ .sort((a, b) => b.revenue - a.revenue);
|
|
|
|
|
+ const ranking = allShops.slice(0, 10);
|
|
|
|
|
+ const bottom = allShops.slice(-5).reverse();
|
|
|
|
|
|
|
|
const funnel = {
|
|
const funnel = {
|
|
|
exposure: cur.exposure,
|
|
exposure: cur.exposure,
|
|
@@ -405,11 +408,66 @@ async function buildDashboard(store, { from, to, schools, shops }) {
|
|
|
kpis,
|
|
kpis,
|
|
|
trend,
|
|
trend,
|
|
|
ranking,
|
|
ranking,
|
|
|
|
|
+ bottom,
|
|
|
|
|
+ shopCount: allShops.length,
|
|
|
funnel,
|
|
funnel,
|
|
|
structure,
|
|
structure,
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+const INSIGHT_SYSTEM_PROMPT = [
|
|
|
|
|
+ '你是连锁餐饮公司的经营分析师。用户会给一段 JSON,包含某段时间的经营汇总(kpis)、按日趋势(trend)、门店排行(ranking)与垫底(bottom)、转化漏斗(funnel)、收入支出结构(structure)。',
|
|
|
|
|
+ '请用中文输出 3~5 条要点,每行一条,以「- 」开头。要求:',
|
|
|
|
|
+ '1. 先给结论,再给依据;',
|
|
|
|
|
+ '2. 必须引用 JSON 里的具体数字(金额保留两位小数、人数/单量用整数、比率用百分比);',
|
|
|
|
|
+ '3. 指出风险(下滑、零单门店、成本占比高、漏斗转化偏低等),并给一句可执行建议;',
|
|
|
|
|
+ '4. 数据不足(如 prev 为 null、只有 1 天)就直接说明,不要编造;',
|
|
|
|
|
+ '5. 每条不超过 60 字,不要输出 JSON、标题或前后缀。',
|
|
|
|
|
+].join('\n');
|
|
|
|
|
+
|
|
|
|
|
+function insightContext(data) {
|
|
|
|
|
+ const k = (key) => (data.kpis[key] ? { value: data.kpis[key].value, delta: data.kpis[key].delta } : null);
|
|
|
|
|
+ const rate = (a, b) => (b > 0 ? Math.round((a / b) * 1000) / 10 : null);
|
|
|
|
|
+ const funnel = data.funnel || {};
|
|
|
|
|
+ return {
|
|
|
|
|
+ range: { from: data.from, to: data.to, days: data.trend.length, prev: data.prev },
|
|
|
|
|
+ kpis: {
|
|
|
|
|
+ revenue: k('revenue'),
|
|
|
|
|
+ orders: k('orders'),
|
|
|
|
|
+ avgTicket: k('avgTicket'),
|
|
|
|
|
+ exposure: k('exposure'),
|
|
|
|
|
+ conversion: k('conversion'),
|
|
|
|
|
+ rating: k('rating'),
|
|
|
|
|
+ },
|
|
|
|
|
+ funnel: {
|
|
|
|
|
+ exposure: funnel.exposure,
|
|
|
|
|
+ visit: funnel.visit,
|
|
|
|
|
+ order: funnel.order,
|
|
|
|
|
+ visitRate: rate(funnel.visit, funnel.exposure),
|
|
|
|
|
+ orderRate: rate(funnel.order, funnel.visit),
|
|
|
|
|
+ },
|
|
|
|
|
+ trend: data.trend.slice(-14),
|
|
|
|
|
+ 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 })),
|
|
|
|
|
+ structure: (data.structure || []).map((s) => ({
|
|
|
|
|
+ date: s.date,
|
|
|
|
|
+ income: s.income,
|
|
|
|
|
+ expense: s.expense,
|
|
|
|
|
+ subsidy: s.subsidy,
|
|
|
|
|
+ expenseRatio: s.income > 0 ? Math.round((s.expense / s.income) * 1000) / 10 : null,
|
|
|
|
|
+ })),
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function parseInsightPoints(text) {
|
|
|
|
|
+ return String(text || '')
|
|
|
|
|
+ .split(/\r?\n/)
|
|
|
|
|
+ .map((line) => line.replace(/^\s*(?:[-*•]|\d+[.、)])\s*/, '').trim())
|
|
|
|
|
+ .filter(Boolean)
|
|
|
|
|
+ .slice(0, 6);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function csvCell(value) {
|
|
function csvCell(value) {
|
|
|
const text = value == null ? '' : String(value);
|
|
const text = value == null ? '' : String(value);
|
|
|
return /[",\n\r]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text;
|
|
return /[",\n\r]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text;
|
|
@@ -655,6 +713,66 @@ function register(app, ctx) {
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ const insightCache = new Map();
|
|
|
|
|
+ const INSIGHT_TTL_MS = 10 * 60 * 1000;
|
|
|
|
|
+
|
|
|
|
|
+ app.post('/api/ops/insights', async ({ req, res }) => {
|
|
|
|
|
+ if (!(await requireHealthy(store, res))) return;
|
|
|
|
|
+ const chatCfg = makeChatCfg(ctx);
|
|
|
|
|
+ if (!chatCfg.chatApiKey || chatCfg.chatApiKey === 'your-api-key-here') {
|
|
|
|
|
+ return sendJson(res, 200, { enabled: false, reason: 'chat-not-configured' });
|
|
|
|
|
+ }
|
|
|
|
|
+ const body = await readBody(req);
|
|
|
|
|
+ const params = {
|
|
|
|
|
+ from: parseYmd(body.from) || undefined,
|
|
|
|
|
+ to: parseYmd(body.to) || undefined,
|
|
|
|
|
+ schools: splitList(body.schools),
|
|
|
|
|
+ shops: splitList(body.shops),
|
|
|
|
|
+ };
|
|
|
|
|
+ const cacheKey = JSON.stringify(params);
|
|
|
|
|
+ const cached = insightCache.get(cacheKey);
|
|
|
|
|
+ if (!body.refresh && cached && Date.now() - cached.at < INSIGHT_TTL_MS) {
|
|
|
|
|
+ return sendJson(res, 200, { enabled: true, ...cached.value, cached: true });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let data;
|
|
|
|
|
+ try {
|
|
|
|
|
+ data = await buildDashboard(store, params);
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ return sendJson(res, 503, { error: '聚合失败', detail: e.message || String(e) });
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!data.kpis) {
|
|
|
|
|
+ return sendJson(res, 200, { enabled: true, points: [], note: '所选范围没有数据' });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const context = insightContext(data);
|
|
|
|
|
+ let text;
|
|
|
|
|
+ try {
|
|
|
|
|
+ text = await chatCompletion({
|
|
|
|
|
+ apiBase: chatCfg.chatApiBase,
|
|
|
|
|
+ apiKey: chatCfg.chatApiKey,
|
|
|
|
|
+ model: chatCfg.chatModel,
|
|
|
|
|
+ temperature: 0.3,
|
|
|
|
|
+ timeoutMs: 45000,
|
|
|
|
|
+ messages: [
|
|
|
|
|
+ { role: 'system', content: INSIGHT_SYSTEM_PROMPT },
|
|
|
|
|
+ { role: 'user', content: JSON.stringify(context) },
|
|
|
|
|
+ ],
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ return sendJson(res, 502, { enabled: true, error: '模型调用失败', detail: e.message || String(e) });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const value = {
|
|
|
|
|
+ points: parseInsightPoints(text),
|
|
|
|
|
+ model: chatCfg.chatModel,
|
|
|
|
|
+ generatedAt: new Date().toISOString(),
|
|
|
|
|
+ digest: context,
|
|
|
|
|
+ };
|
|
|
|
|
+ insightCache.set(cacheKey, { at: Date.now(), value });
|
|
|
|
|
+ sendJson(res, 200, { enabled: true, ...value });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
app.get('/api/ops/dashboard', async ({ res, query }) => {
|
|
app.get('/api/ops/dashboard', async ({ res, query }) => {
|
|
|
if (!(await requireHealthy(store, res))) return;
|
|
if (!(await requireHealthy(store, res))) return;
|
|
|
const from = parseYmd(query.from);
|
|
const from = parseYmd(query.from);
|