|
|
@@ -0,0 +1,842 @@
|
|
|
+const path = require('path');
|
|
|
+const { randomUUID, createHash } = require('crypto');
|
|
|
+const { DatabaseSync } = require('node:sqlite');
|
|
|
+const { outputsRoot } = require('./output-paths');
|
|
|
+
|
|
|
+function now() { return new Date().toISOString(); }
|
|
|
+function id(prefix) { return `${prefix}_${randomUUID()}`; }
|
|
|
+function json(value) { return JSON.stringify(value ?? null); }
|
|
|
+function parse(value, fallback) {
|
|
|
+ try { return JSON.parse(value); } catch { return fallback; }
|
|
|
+}
|
|
|
+function hash(value) { return createHash('sha256').update(String(value)).digest('hex'); }
|
|
|
+function accountKey(value) { return String(value || 'local-default').trim() || 'local-default'; }
|
|
|
+function isQuestion(content) { return /[??]|怎么|如何|多少|什么时候|可以吗|有没有|是否|哪[里个]|为何|为什么/.test(String(content || '')); }
|
|
|
+function meaningfulResponse(question, response) {
|
|
|
+ const answer = String(response || '').trim();
|
|
|
+ if (!answer) return false;
|
|
|
+ const prompt = String(question || '').trim();
|
|
|
+ const genericNoise = /^(收到|好的|嗯|哦|稍等|在的|您好|你好|谢谢)[!!。,.,\s]*$/;
|
|
|
+ if (genericNoise.test(answer)) return false;
|
|
|
+ const intentRules = [
|
|
|
+ [/什么时候|多久|几点|日期|时间/, /(今天|明天|后天|上午|下午|晚上|点|分钟|小时|天|周|月|预计|之前|之后|稍后).*(处理|到账|完成|联系|回复|安排|提交|发货|到店|生效)|(处理|到账|完成|联系|回复|安排|提交|发货|到店|生效).*(今天|明天|后天|上午|下午|晚上|点|分钟|小时|天|周|月|预计|之前|之后|稍后)/],
|
|
|
+ [/多少|价格|费用|金额|退款/, /元|块|价格|费用|金额|退款|原路|到账|支付/],
|
|
|
+ [/怎么|如何|流程|办法/, /请|需要|可以|步骤|先|再|提交|联系|操作|处理/],
|
|
|
+ [/可以吗|能否|能不能|是否|有没有/, /可以|不可以|能|不能|有|没有|支持|暂不|需要/]
|
|
|
+ ];
|
|
|
+ const matchedIntent = intentRules.find(([questionPattern]) => questionPattern.test(prompt));
|
|
|
+ if (matchedIntent && matchedIntent[1].test(answer)) return true;
|
|
|
+ const tokens = [...new Set((prompt.match(/[\u4e00-\u9fff]{2,}|[a-zA-Z0-9]{2,}/g) || [])
|
|
|
+ .flatMap(token => token.length > 4 && /^[\u4e00-\u9fff]+$/.test(token) ? Array.from({ length: token.length - 1 }, (_, index) => token.slice(index, index + 2)) : [token])
|
|
|
+ .filter(token => !['请问', '什么', '怎么', '如何', '可以', '有没有', '时候'].includes(token)))];
|
|
|
+ return tokens.some(token => answer.includes(token));
|
|
|
+}
|
|
|
+function diffObjects(before = {}, after = {}) {
|
|
|
+ const keys = [...new Set([...Object.keys(before || {}), ...Object.keys(after || {})])];
|
|
|
+ return keys.filter(key => json(before?.[key]) !== json(after?.[key])).map(key => ({ key, before: before?.[key], after: after?.[key] }));
|
|
|
+}
|
|
|
+function shanghaiParts(value = new Date()) {
|
|
|
+ const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short', hour: '2-digit', minute: '2-digit', hour12: false }).formatToParts(value);
|
|
|
+ const get = type => parts.find(item => item.type === type)?.value || '';
|
|
|
+ const weekdays = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 };
|
|
|
+ return { date: `${get('year')}-${get('month')}-${get('day')}`, time: `${get('hour')}:${get('minute')}`, weekday: weekdays[get('weekday')] || 1 };
|
|
|
+}
|
|
|
+
|
|
|
+class GroupOperationsStore {
|
|
|
+ constructor(filePath = path.join(outputsRoot(), 'group-operations', 'group-operations.db')) {
|
|
|
+ require('fs').mkdirSync(path.dirname(filePath), { recursive: true });
|
|
|
+ this.filePath = filePath;
|
|
|
+ this.db = new DatabaseSync(filePath);
|
|
|
+ this.db.exec('PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;');
|
|
|
+ this.init();
|
|
|
+ }
|
|
|
+
|
|
|
+ init() {
|
|
|
+ this.db.exec(`
|
|
|
+ CREATE TABLE IF NOT EXISTS group_ops_groups (
|
|
|
+ id TEXT PRIMARY KEY, account_key TEXT NOT NULL, room_id TEXT NOT NULL,
|
|
|
+ room_name TEXT NOT NULL DEFAULT '', store_id TEXT NOT NULL DEFAULT '', owner_id TEXT NOT NULL DEFAULT '',
|
|
|
+ group_type TEXT NOT NULL DEFAULT 'customer', lifecycle_stage TEXT NOT NULL DEFAULT 'active',
|
|
|
+ playbook_id TEXT, status TEXT NOT NULL DEFAULT 'active', created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
|
+ UNIQUE(account_key, room_id)
|
|
|
+ );
|
|
|
+ CREATE TABLE IF NOT EXISTS group_ops_playbooks (
|
|
|
+ id TEXT PRIMARY KEY, account_key TEXT NOT NULL, name TEXT NOT NULL, group_type TEXT NOT NULL DEFAULT 'customer',
|
|
|
+ status TEXT NOT NULL DEFAULT 'draft', current_version_id TEXT, scope_json TEXT NOT NULL DEFAULT '{}',
|
|
|
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
|
+ );
|
|
|
+ CREATE TABLE IF NOT EXISTS group_ops_playbook_versions (
|
|
|
+ id TEXT PRIMARY KEY, playbook_id TEXT NOT NULL REFERENCES group_ops_playbooks(id) ON DELETE CASCADE,
|
|
|
+ version INTEGER NOT NULL, content_json TEXT NOT NULL, change_note TEXT NOT NULL DEFAULT '',
|
|
|
+ created_by TEXT NOT NULL DEFAULT 'agent', created_at TEXT NOT NULL, published_at TEXT,
|
|
|
+ UNIQUE(playbook_id, version)
|
|
|
+ );
|
|
|
+ CREATE TABLE IF NOT EXISTS group_ops_plans (
|
|
|
+ id TEXT PRIMARY KEY, account_key TEXT NOT NULL, room_id TEXT NOT NULL, plan_date TEXT NOT NULL,
|
|
|
+ playbook_version_id TEXT, status TEXT NOT NULL DEFAULT 'draft', generated_by TEXT NOT NULL DEFAULT 'agent',
|
|
|
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(account_key, room_id, plan_date)
|
|
|
+ );
|
|
|
+ CREATE TABLE IF NOT EXISTS group_ops_plan_items (
|
|
|
+ id TEXT PRIMARY KEY, plan_id TEXT NOT NULL REFERENCES group_ops_plans(id) ON DELETE CASCADE,
|
|
|
+ scheduled_at TEXT NOT NULL DEFAULT '', type TEXT NOT NULL DEFAULT 'content', objective TEXT NOT NULL DEFAULT '',
|
|
|
+ draft_content TEXT NOT NULL DEFAULT '', final_content TEXT NOT NULL DEFAULT '',
|
|
|
+ status TEXT NOT NULL DEFAULT 'pending_review', idempotency_key TEXT NOT NULL UNIQUE,
|
|
|
+ skip_reason TEXT NOT NULL DEFAULT '', external_message_id TEXT NOT NULL DEFAULT '',
|
|
|
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
|
+ );
|
|
|
+ CREATE TABLE IF NOT EXISTS group_ops_messages (
|
|
|
+ id TEXT PRIMARY KEY, account_key TEXT NOT NULL, room_id TEXT NOT NULL, external_message_id TEXT NOT NULL,
|
|
|
+ sender_id TEXT NOT NULL DEFAULT '', sender_role TEXT NOT NULL DEFAULT 'unknown', content TEXT NOT NULL DEFAULT '',
|
|
|
+ message_type TEXT NOT NULL DEFAULT 'text', sent_at TEXT NOT NULL, raw_json TEXT NOT NULL DEFAULT '{}',
|
|
|
+ created_at TEXT NOT NULL, UNIQUE(account_key, external_message_id)
|
|
|
+ );
|
|
|
+ CREATE TABLE IF NOT EXISTS group_ops_findings (
|
|
|
+ id TEXT PRIMARY KEY, account_key TEXT NOT NULL, room_id TEXT NOT NULL, fingerprint TEXT NOT NULL,
|
|
|
+ type TEXT NOT NULL, severity TEXT NOT NULL DEFAULT 'medium', title TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '',
|
|
|
+ evidence_json TEXT NOT NULL DEFAULT '[]', rule_version TEXT NOT NULL DEFAULT 'mvp-1', confidence REAL NOT NULL DEFAULT 1,
|
|
|
+ status TEXT NOT NULL DEFAULT 'open', assignee TEXT NOT NULL DEFAULT '', due_at TEXT NOT NULL DEFAULT '',
|
|
|
+ resolution TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
|
+ UNIQUE(account_key, room_id, fingerprint)
|
|
|
+ );
|
|
|
+ CREATE TABLE IF NOT EXISTS group_ops_audit_logs (
|
|
|
+ id TEXT PRIMARY KEY, account_key TEXT NOT NULL, actor TEXT NOT NULL, action TEXT NOT NULL,
|
|
|
+ entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, before_json TEXT NOT NULL DEFAULT '{}',
|
|
|
+ after_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL
|
|
|
+ );
|
|
|
+ CREATE TABLE IF NOT EXISTS group_ops_tasks (
|
|
|
+ id TEXT PRIMARY KEY, account_key TEXT NOT NULL, room_id TEXT NOT NULL DEFAULT '', finding_id TEXT,
|
|
|
+ title TEXT NOT NULL, owner TEXT NOT NULL DEFAULT '', due_at TEXT NOT NULL DEFAULT '', priority TEXT NOT NULL DEFAULT 'medium',
|
|
|
+ status TEXT NOT NULL DEFAULT 'open', official_todo_id TEXT NOT NULL DEFAULT '', official_sync_status TEXT NOT NULL DEFAULT '',
|
|
|
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
|
+ );
|
|
|
+ CREATE TABLE IF NOT EXISTS group_ops_quality_settings (
|
|
|
+ account_key TEXT PRIMARY KEY, sla_minutes INTEGER NOT NULL DEFAULT 120,
|
|
|
+ risk_terms_json TEXT NOT NULL DEFAULT '[]', forbidden_claims_json TEXT NOT NULL DEFAULT '[]', updated_at TEXT NOT NULL
|
|
|
+ );
|
|
|
+ CREATE TABLE IF NOT EXISTS group_ops_feedback (
|
|
|
+ id TEXT PRIMARY KEY, account_key TEXT NOT NULL, finding_id TEXT NOT NULL, label TEXT NOT NULL,
|
|
|
+ note TEXT NOT NULL DEFAULT '', actor TEXT NOT NULL DEFAULT 'human', created_at TEXT NOT NULL
|
|
|
+ );
|
|
|
+ CREATE TABLE IF NOT EXISTS group_ops_automation_policies (
|
|
|
+ id TEXT PRIMARY KEY, account_key TEXT NOT NULL, version INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'draft',
|
|
|
+ policy_json TEXT NOT NULL, change_note TEXT NOT NULL DEFAULT '', created_by TEXT NOT NULL DEFAULT 'agent',
|
|
|
+ created_at TEXT NOT NULL, activated_at TEXT, UNIQUE(account_key, version)
|
|
|
+ );
|
|
|
+ CREATE TABLE IF NOT EXISTS group_ops_role_bindings (
|
|
|
+ id TEXT PRIMARY KEY, account_key TEXT NOT NULL, principal TEXT NOT NULL,
|
|
|
+ role TEXT NOT NULL, store_scope_json TEXT NOT NULL DEFAULT '[]', status TEXT NOT NULL DEFAULT 'active',
|
|
|
+ created_by TEXT NOT NULL DEFAULT 'system', created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
|
+ UNIQUE(account_key, principal)
|
|
|
+ );
|
|
|
+ CREATE TABLE IF NOT EXISTS group_ops_automation_state (
|
|
|
+ account_key TEXT PRIMARY KEY, paused INTEGER NOT NULL DEFAULT 1, consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
|
|
+ breaker_open INTEGER NOT NULL DEFAULT 0, last_failure TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL
|
|
|
+ );
|
|
|
+ CREATE TABLE IF NOT EXISTS group_ops_send_attempts (
|
|
|
+ id TEXT PRIMARY KEY, account_key TEXT NOT NULL, plan_item_id TEXT NOT NULL, room_id TEXT NOT NULL,
|
|
|
+ idempotency_key TEXT NOT NULL, mode TEXT NOT NULL, status TEXT NOT NULL, decision_json TEXT NOT NULL DEFAULT '{}',
|
|
|
+ external_message_id TEXT NOT NULL DEFAULT '', error TEXT NOT NULL DEFAULT '', policy_version INTEGER,
|
|
|
+ created_at TEXT NOT NULL, completed_at TEXT
|
|
|
+ );
|
|
|
+ CREATE TABLE IF NOT EXISTS group_ops_daily_scores (
|
|
|
+ account_key TEXT NOT NULL, room_id TEXT NOT NULL, score_date TEXT NOT NULL,
|
|
|
+ component_json TEXT NOT NULL DEFAULT '{}', coverage REAL NOT NULL DEFAULT 0,
|
|
|
+ total_score REAL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
|
+ PRIMARY KEY(account_key, room_id, score_date)
|
|
|
+ );
|
|
|
+ CREATE INDEX IF NOT EXISTS idx_group_ops_plan_day ON group_ops_plans(account_key, plan_date, status);
|
|
|
+ CREATE INDEX IF NOT EXISTS idx_group_ops_findings ON group_ops_findings(account_key, status, severity, created_at DESC);
|
|
|
+ CREATE INDEX IF NOT EXISTS idx_group_ops_messages ON group_ops_messages(account_key, room_id, sent_at);
|
|
|
+ CREATE INDEX IF NOT EXISTS idx_group_ops_tasks ON group_ops_tasks(account_key, status, due_at);
|
|
|
+ CREATE INDEX IF NOT EXISTS idx_group_ops_feedback ON group_ops_feedback(account_key, label, created_at DESC);
|
|
|
+ CREATE INDEX IF NOT EXISTS idx_group_ops_policy ON group_ops_automation_policies(account_key, status, version DESC);
|
|
|
+ CREATE INDEX IF NOT EXISTS idx_group_ops_roles ON group_ops_role_bindings(account_key, principal, status);
|
|
|
+ CREATE INDEX IF NOT EXISTS idx_group_ops_attempts ON group_ops_send_attempts(account_key, room_id, status, created_at DESC);
|
|
|
+ CREATE INDEX IF NOT EXISTS idx_group_ops_scores ON group_ops_daily_scores(account_key, score_date, total_score);
|
|
|
+ `);
|
|
|
+ }
|
|
|
+
|
|
|
+ audit(key, actor, action, entityType, entityId, before = {}, after = {}) {
|
|
|
+ const auditId = id('goa');
|
|
|
+ this.db.prepare('INSERT INTO group_ops_audit_logs VALUES(?,?,?,?,?,?,?,?,?)')
|
|
|
+ .run(auditId, accountKey(key), actor || 'agent', action, entityType, entityId, json(before), json(after), now());
|
|
|
+ return auditId;
|
|
|
+ }
|
|
|
+
|
|
|
+ upsertGroup(key, group = {}, actor = 'agent') {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const roomId = String(group.roomId || group.room_id || '').trim();
|
|
|
+ if (!roomId) throw new Error('缺少 roomId');
|
|
|
+ const before = this.getGroup(account, roomId);
|
|
|
+ const timestamp = now();
|
|
|
+ if (before) {
|
|
|
+ this.db.prepare(`UPDATE group_ops_groups SET room_name=?,store_id=?,owner_id=?,group_type=?,lifecycle_stage=?,
|
|
|
+ playbook_id=COALESCE(?,playbook_id),status=?,updated_at=? WHERE account_key=? AND room_id=?`)
|
|
|
+ .run(group.roomName ?? before.room_name, group.storeId ?? before.store_id, group.ownerId ?? before.owner_id,
|
|
|
+ group.groupType ?? before.group_type, group.lifecycleStage ?? before.lifecycle_stage, group.playbookId || null,
|
|
|
+ group.status ?? before.status, timestamp, account, roomId);
|
|
|
+ } else {
|
|
|
+ this.db.prepare(`INSERT INTO group_ops_groups VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
|
+ .run(id('gog'), account, roomId, group.roomName || '', group.storeId || '', group.ownerId || '',
|
|
|
+ group.groupType || 'customer', group.lifecycleStage || 'active', group.playbookId || null,
|
|
|
+ group.status || 'active', timestamp, timestamp);
|
|
|
+ }
|
|
|
+ const after = this.getGroup(account, roomId);
|
|
|
+ this.audit(account, actor, before ? 'group.update' : 'group.create', 'group', after.id, before || {}, after);
|
|
|
+ return after;
|
|
|
+ }
|
|
|
+
|
|
|
+ getGroup(key, roomId) {
|
|
|
+ return this.db.prepare('SELECT * FROM group_ops_groups WHERE account_key=? AND room_id=?').get(accountKey(key), String(roomId)) || null;
|
|
|
+ }
|
|
|
+
|
|
|
+ listGroups(key) {
|
|
|
+ return this.db.prepare('SELECT * FROM group_ops_groups WHERE account_key=? ORDER BY updated_at DESC').all(accountKey(key));
|
|
|
+ }
|
|
|
+
|
|
|
+ listRoleBindings(key) {
|
|
|
+ return this.db.prepare(`SELECT * FROM group_ops_role_bindings WHERE account_key=? ORDER BY principal`)
|
|
|
+ .all(accountKey(key)).map(row => ({ ...row, storeScope: parse(row.store_scope_json, []) }));
|
|
|
+ }
|
|
|
+
|
|
|
+ getRoleBinding(key, principal) {
|
|
|
+ const row = this.db.prepare(`SELECT * FROM group_ops_role_bindings WHERE account_key=? AND principal=? AND status='active'`)
|
|
|
+ .get(accountKey(key), String(principal || '').trim());
|
|
|
+ return row ? { ...row, storeScope: parse(row.store_scope_json, []) } : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ upsertRoleBinding(key, input = {}, actor = 'system') {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const principal = String(input.principal || '').trim();
|
|
|
+ if (!principal) throw new Error('缺少 principal(通常为企微 userId)');
|
|
|
+ const role = String(input.role || '').trim();
|
|
|
+ const storeScope = [...new Set((input.storeScope || input.storeIds || []).map(String).map(item => item.trim()).filter(Boolean))];
|
|
|
+ const before = this.getRoleBinding(account, principal);
|
|
|
+ const timestamp = now();
|
|
|
+ const bindingId = before?.id || id('gor');
|
|
|
+ this.db.prepare(`INSERT INTO group_ops_role_bindings VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(account_key,principal) DO UPDATE SET
|
|
|
+ role=excluded.role,store_scope_json=excluded.store_scope_json,status=excluded.status,updated_at=excluded.updated_at`)
|
|
|
+ .run(bindingId, account, principal, role, json(storeScope), input.status || 'active', actor, before?.created_at || timestamp, timestamp);
|
|
|
+ const after = this.getRoleBinding(account, principal);
|
|
|
+ this.audit(account, actor, 'role_binding.upsert', 'role_binding', bindingId, before || {}, after || {});
|
|
|
+ return after;
|
|
|
+ }
|
|
|
+
|
|
|
+ createPlaybook(key, input = {}, actor = 'agent') {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const playbookId = id('gopb');
|
|
|
+ const versionId = id('gopbv');
|
|
|
+ const timestamp = now();
|
|
|
+ const content = input.content || { cadence: [], templates: [], rules: {} };
|
|
|
+ this.db.exec('BEGIN');
|
|
|
+ try {
|
|
|
+ this.db.prepare('INSERT INTO group_ops_playbooks VALUES(?,?,?,?,?,?,?,?,?)')
|
|
|
+ .run(playbookId, account, String(input.name || '未命名群运营 SOP'), input.groupType || 'customer', 'draft', null, json(input.scope || {}), timestamp, timestamp);
|
|
|
+ this.db.prepare('INSERT INTO group_ops_playbook_versions VALUES(?,?,?,?,?,?,?,?)')
|
|
|
+ .run(versionId, playbookId, 1, json(content), input.changeNote || '创建初始版本', actor, timestamp, null);
|
|
|
+ this.db.exec('COMMIT');
|
|
|
+ } catch (error) { this.db.exec('ROLLBACK'); throw error; }
|
|
|
+ const result = this.getPlaybook(account, playbookId);
|
|
|
+ this.audit(account, actor, 'playbook.create', 'playbook', playbookId, {}, result);
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ listPlaybooks(key) {
|
|
|
+ return this.db.prepare(`SELECT p.*,v.version,v.content_json,v.published_at FROM group_ops_playbooks p
|
|
|
+ LEFT JOIN group_ops_playbook_versions v ON v.id=CASE WHEN p.status='published' THEN p.current_version_id ELSE
|
|
|
+ (SELECT id FROM group_ops_playbook_versions x WHERE x.playbook_id=p.id ORDER BY version DESC LIMIT 1)
|
|
|
+ END
|
|
|
+ WHERE p.account_key=? ORDER BY p.updated_at DESC`).all(accountKey(key)).map(this.hydratePlaybook);
|
|
|
+ }
|
|
|
+
|
|
|
+ hydratePlaybook(row) { return row ? { ...row, scope: parse(row.scope_json, {}), content: parse(row.content_json, {}) } : null; }
|
|
|
+
|
|
|
+ getPlaybook(key, playbookId) {
|
|
|
+ const row = this.db.prepare(`SELECT p.*,v.version,v.content_json,v.published_at FROM group_ops_playbooks p
|
|
|
+ LEFT JOIN group_ops_playbook_versions v ON v.id=COALESCE(p.current_version_id,
|
|
|
+ (SELECT id FROM group_ops_playbook_versions x WHERE x.playbook_id=p.id ORDER BY version DESC LIMIT 1))
|
|
|
+ WHERE p.account_key=? AND p.id=?`).get(accountKey(key), playbookId);
|
|
|
+ return this.hydratePlaybook(row);
|
|
|
+ }
|
|
|
+
|
|
|
+ addPlaybookVersion(key, playbookId, content, changeNote = '', actor = 'agent') {
|
|
|
+ const before = this.getPlaybook(key, playbookId);
|
|
|
+ if (!before) throw new Error('SOP 不存在或不属于当前账号');
|
|
|
+ const next = Number(this.db.prepare('SELECT MAX(version) value FROM group_ops_playbook_versions WHERE playbook_id=?').get(playbookId)?.value || 0) + 1;
|
|
|
+ const versionId = id('gopbv');
|
|
|
+ this.db.prepare('INSERT INTO group_ops_playbook_versions VALUES(?,?,?,?,?,?,?,?)')
|
|
|
+ .run(versionId, playbookId, next, json(content || {}), changeNote, actor, now(), null);
|
|
|
+ this.db.prepare("UPDATE group_ops_playbooks SET status=CASE WHEN current_version_id IS NULL THEN 'draft' ELSE status END,updated_at=? WHERE id=?").run(now(), playbookId);
|
|
|
+ const after = this.getPlaybook(key, playbookId);
|
|
|
+ this.audit(key, actor, 'playbook.version.create', 'playbook', playbookId, before, after);
|
|
|
+ return { ...after, version: next, draftVersion: next, draftVersionId: versionId, draftContent: content || {} };
|
|
|
+ }
|
|
|
+
|
|
|
+ publishPlaybook(key, playbookId, version, actor = 'agent') {
|
|
|
+ const before = this.getPlaybook(key, playbookId);
|
|
|
+ if (!before) throw new Error('SOP 不存在或不属于当前账号');
|
|
|
+ const target = version
|
|
|
+ ? this.db.prepare('SELECT * FROM group_ops_playbook_versions WHERE playbook_id=? AND version=?').get(playbookId, Number(version))
|
|
|
+ : this.db.prepare('SELECT * FROM group_ops_playbook_versions WHERE playbook_id=? ORDER BY version DESC LIMIT 1').get(playbookId);
|
|
|
+ if (!target) throw new Error('SOP 版本不存在');
|
|
|
+ const timestamp = now();
|
|
|
+ this.db.prepare('UPDATE group_ops_playbook_versions SET published_at=COALESCE(published_at,?) WHERE id=?').run(timestamp, target.id);
|
|
|
+ this.db.prepare("UPDATE group_ops_playbooks SET status='published',current_version_id=?,updated_at=? WHERE id=?").run(target.id, timestamp, playbookId);
|
|
|
+ const after = this.getPlaybook(key, playbookId);
|
|
|
+ this.audit(key, actor, 'playbook.publish', 'playbook', playbookId, before, after);
|
|
|
+ return after;
|
|
|
+ }
|
|
|
+
|
|
|
+ listPlaybookVersions(key, playbookId) {
|
|
|
+ const playbook = this.db.prepare('SELECT * FROM group_ops_playbooks WHERE id=? AND account_key=?').get(playbookId, accountKey(key));
|
|
|
+ if (!playbook) throw new Error('SOP 不存在或不属于当前账号');
|
|
|
+ return this.db.prepare('SELECT * FROM group_ops_playbook_versions WHERE playbook_id=? ORDER BY version DESC').all(playbookId)
|
|
|
+ .map(row => ({ ...row, content: parse(row.content_json, {}) }));
|
|
|
+ }
|
|
|
+
|
|
|
+ previewPlaybookPublish(key, playbookId, version) {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const playbook = this.db.prepare('SELECT * FROM group_ops_playbooks WHERE id=? AND account_key=?').get(playbookId, account);
|
|
|
+ if (!playbook) throw new Error('SOP 不存在或不属于当前账号');
|
|
|
+ const versions = this.listPlaybookVersions(account, playbookId);
|
|
|
+ const target = version ? versions.find(item => item.version === Number(version)) : versions[0];
|
|
|
+ if (!target) throw new Error('目标版本不存在');
|
|
|
+ const current = versions.find(item => item.id === playbook.current_version_id) || null;
|
|
|
+ const groups = this.db.prepare(`SELECT room_id,room_name,store_id,group_type,lifecycle_stage FROM group_ops_groups
|
|
|
+ WHERE account_key=? AND status='active' AND (playbook_id=? OR (playbook_id IS NULL AND group_type=?)) ORDER BY store_id,room_name`).all(account, playbookId, playbook.group_type);
|
|
|
+ return {
|
|
|
+ playbookId, name: playbook.name, targetVersion: target.version, currentVersion: current?.version || null,
|
|
|
+ changes: diffObjects(current?.content || {}, target.content || {}), affectedGroupCount: groups.length,
|
|
|
+ affectedStores: [...new Set(groups.map(item => item.store_id).filter(Boolean))], groups
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ generatePlan(key, input = {}, actor = 'agent') {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const roomId = String(input.roomId || '').trim();
|
|
|
+ if (!roomId) throw new Error('缺少 roomId');
|
|
|
+ const group = this.getGroup(account, roomId) || this.upsertGroup(account, input.group || { roomId, roomName: input.roomName || '' }, actor);
|
|
|
+ const planDate = String(input.planDate || new Date().toISOString().slice(0, 10));
|
|
|
+ const existing = this.db.prepare('SELECT * FROM group_ops_plans WHERE account_key=? AND room_id=? AND plan_date=?').get(account, roomId, planDate);
|
|
|
+ if (existing) return this.getPlan(existing.id);
|
|
|
+ const playbook = input.playbookId
|
|
|
+ ? this.getPlaybook(account, input.playbookId)
|
|
|
+ : (group.playbook_id
|
|
|
+ ? this.getPlaybook(account, group.playbook_id)
|
|
|
+ : this.listPlaybooks(account).find(item => item.status === 'published' && item.group_type === group.group_type));
|
|
|
+ if (playbook && playbook.status !== 'published' && !(Array.isArray(input.items) && input.items.length)) {
|
|
|
+ throw new Error('只能依据已发布 SOP 生成计划');
|
|
|
+ }
|
|
|
+ const items = Array.isArray(input.items) && input.items.length ? input.items : (playbook?.content?.cadence || []);
|
|
|
+ if (!items.length) throw new Error('没有可生成的计划项,请传入 items 或先发布包含 cadence 的 SOP');
|
|
|
+ const planId = id('goplan');
|
|
|
+ const timestamp = now();
|
|
|
+ this.db.prepare('INSERT INTO group_ops_plans VALUES(?,?,?,?,?,?,?,?,?)')
|
|
|
+ .run(planId, account, roomId, planDate, playbook?.current_version_id || null, 'draft', actor, timestamp, timestamp);
|
|
|
+ const insert = this.db.prepare('INSERT INTO group_ops_plan_items VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)');
|
|
|
+ items.forEach((item, index) => {
|
|
|
+ const content = String(item.content || item.template || '').replace(/\{\{room_name\}\}/g, group.room_name || input.roomName || '客户群');
|
|
|
+ insert.run(id('gopi'), planId, item.scheduledAt || `${planDate}T${item.time || '09:30'}:00+08:00`, item.type || 'content',
|
|
|
+ item.objective || '', content, '', 'pending_review', hash(`${account}|${roomId}|${planDate}|${index}|${item.type || 'content'}`), '', '', timestamp, timestamp);
|
|
|
+ });
|
|
|
+ this.audit(account, actor, 'plan.generate', 'plan', planId, {}, { roomId, planDate, itemCount: items.length, playbookId: playbook?.id || null });
|
|
|
+ return this.getPlan(planId);
|
|
|
+ }
|
|
|
+
|
|
|
+ batchGeneratePlans(key, input = {}, actor = 'agent') {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const selected = Array.isArray(input.groups) && input.groups.length
|
|
|
+ ? input.groups
|
|
|
+ : this.listGroups(account).filter(group => !input.storeId || group.store_id === input.storeId);
|
|
|
+ const results = [];
|
|
|
+ for (const group of selected) {
|
|
|
+ try {
|
|
|
+ const plan = this.generatePlan(account, { ...input, roomId: group.roomId || group.room_id, roomName: group.roomName || group.room_name, group }, actor);
|
|
|
+ results.push({ roomId: plan.room_id, status: 'ok', planId: plan.id, itemCount: plan.items.length });
|
|
|
+ } catch (error) {
|
|
|
+ results.push({ roomId: group.roomId || group.room_id, status: 'error', error: error.message });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ this.audit(account, actor, 'plan.batch_generate', 'plan_batch', `${input.planDate || new Date().toISOString().slice(0, 10)}`, {}, { count: selected.length, success: results.filter(item => item.status === 'ok').length });
|
|
|
+ return { total: selected.length, success: results.filter(item => item.status === 'ok').length, failed: results.filter(item => item.status === 'error').length, results };
|
|
|
+ }
|
|
|
+
|
|
|
+ getPlan(planId) {
|
|
|
+ const plan = this.db.prepare('SELECT * FROM group_ops_plans WHERE id=?').get(planId);
|
|
|
+ if (!plan) return null;
|
|
|
+ return { ...plan, items: this.db.prepare('SELECT * FROM group_ops_plan_items WHERE plan_id=? ORDER BY scheduled_at,id').all(planId) };
|
|
|
+ }
|
|
|
+
|
|
|
+ updatePlanItem(key, itemId, action, fields = {}, actor = 'agent') {
|
|
|
+ const before = this.db.prepare(`SELECT i.*,p.account_key FROM group_ops_plan_items i JOIN group_ops_plans p ON p.id=i.plan_id WHERE i.id=? AND p.account_key=?`).get(itemId, accountKey(key));
|
|
|
+ if (!before) throw new Error('计划项不存在或不属于当前账号');
|
|
|
+ const allowed = { approve: 'approved', reject: 'rejected', skip: 'skipped', mark_sent: 'sent' };
|
|
|
+ if (!allowed[action]) throw new Error('不支持的计划项操作');
|
|
|
+ const finalContent = fields.content === undefined ? before.final_content : String(fields.content);
|
|
|
+ if (action === 'approve' && !String(finalContent || before.draft_content).trim()) throw new Error('最终话术不能为空');
|
|
|
+ this.db.prepare('UPDATE group_ops_plan_items SET status=?,final_content=?,skip_reason=?,external_message_id=?,updated_at=? WHERE id=?')
|
|
|
+ .run(allowed[action], finalContent || (action === 'approve' ? before.draft_content : before.final_content), fields.reason || '', fields.externalMessageId || '', now(), itemId);
|
|
|
+ const after = this.db.prepare('SELECT * FROM group_ops_plan_items WHERE id=?').get(itemId);
|
|
|
+ const auditId = this.audit(key, actor, `plan_item.${action}`, 'plan_item', itemId, before, after);
|
|
|
+ return { ...after, auditId };
|
|
|
+ }
|
|
|
+
|
|
|
+ ingestMessages(key, roomId, messages = []) {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const insert = this.db.prepare('INSERT OR IGNORE INTO group_ops_messages VALUES(?,?,?,?,?,?,?,?,?,?,?)');
|
|
|
+ let inserted = 0;
|
|
|
+ for (const message of messages) {
|
|
|
+ const externalId = String(message.messageId || message.msgId || message.externalMessageId || hash(json(message)));
|
|
|
+ inserted += Number(insert.run(id('gom'), account, roomId, externalId, message.senderId || '', message.senderRole || 'unknown',
|
|
|
+ message.content || '', message.messageType || message.msgType || 'text', message.sentAt || message.timestamp || now(), json(message.raw || message.rawData || {}), now()).changes || 0);
|
|
|
+ }
|
|
|
+ return inserted;
|
|
|
+ }
|
|
|
+
|
|
|
+ getQualitySettings(key) {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const row = this.db.prepare('SELECT * FROM group_ops_quality_settings WHERE account_key=?').get(account);
|
|
|
+ if (!row) return { account_key: account, sla_minutes: 120, risk_terms: ['投诉', '退款', '欺骗', '曝光', '举报', '律师', '赔偿'], forbidden_claims: ['保证', '百分百', '绝对', '最低价', '无条件退款'] };
|
|
|
+ return { ...row, risk_terms: parse(row.risk_terms_json, []), forbidden_claims: parse(row.forbidden_claims_json, []) };
|
|
|
+ }
|
|
|
+
|
|
|
+ setQualitySettings(key, input = {}, actor = 'agent') {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const before = this.getQualitySettings(account);
|
|
|
+ const next = {
|
|
|
+ slaMinutes: Math.max(1, Number(input.slaMinutes || before.sla_minutes || 120)),
|
|
|
+ riskTerms: Array.isArray(input.riskTerms) ? input.riskTerms.map(String).filter(Boolean) : before.risk_terms,
|
|
|
+ forbiddenClaims: Array.isArray(input.forbiddenClaims) ? input.forbiddenClaims.map(String).filter(Boolean) : before.forbidden_claims
|
|
|
+ };
|
|
|
+ this.db.prepare(`INSERT INTO group_ops_quality_settings(account_key,sla_minutes,risk_terms_json,forbidden_claims_json,updated_at)
|
|
|
+ VALUES(?,?,?,?,?) ON CONFLICT(account_key) DO UPDATE SET sla_minutes=excluded.sla_minutes,risk_terms_json=excluded.risk_terms_json,
|
|
|
+ forbidden_claims_json=excluded.forbidden_claims_json,updated_at=excluded.updated_at`)
|
|
|
+ .run(account, next.slaMinutes, json(next.riskTerms), json(next.forbiddenClaims), now());
|
|
|
+ const after = this.getQualitySettings(account);
|
|
|
+ this.audit(account, actor, 'quality_settings.update', 'quality_settings', account, before, after);
|
|
|
+ return after;
|
|
|
+ }
|
|
|
+
|
|
|
+ reviewMessages(key, input = {}, actor = 'agent') {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const roomId = String(input.roomId || '').trim();
|
|
|
+ if (!roomId) throw new Error('缺少 roomId');
|
|
|
+ this.ingestMessages(account, roomId, input.messages || []);
|
|
|
+ const clauses = ['account_key=?', 'room_id=?'];
|
|
|
+ const params = [account, roomId];
|
|
|
+ if (input.startAt) { clauses.push('sent_at>=?'); params.push(String(input.startAt)); }
|
|
|
+ if (input.endAt) { clauses.push('sent_at<=?'); params.push(String(input.endAt)); }
|
|
|
+ const messages = this.db.prepare(`SELECT * FROM group_ops_messages WHERE ${clauses.join(' AND ')} ORDER BY sent_at`).all(...params);
|
|
|
+ const settings = this.getQualitySettings(account);
|
|
|
+ const slaMinutes = Math.max(1, Number(input.slaMinutes || settings.sla_minutes || 120));
|
|
|
+ const riskTerms = input.riskTerms || settings.risk_terms;
|
|
|
+ const findings = [];
|
|
|
+ for (let index = 0; index < messages.length; index++) {
|
|
|
+ const message = messages[index];
|
|
|
+ const content = String(message.content || '');
|
|
|
+ const matched = riskTerms.filter(term => content.includes(term));
|
|
|
+ if (matched.length) findings.push(this.upsertFinding(account, roomId, {
|
|
|
+ fingerprint: `risk:${message.external_message_id}`, type: 'service_risk', severity: matched.some(t => ['投诉', '举报', '律师'].includes(t)) ? 'high' : 'medium',
|
|
|
+ title: '发现客户服务风险', detail: `命中风险词:${matched.join('、')}`, evidence: [{ messageId: message.external_message_id, content, sentAt: message.sent_at }], confidence: 1
|
|
|
+ }, actor));
|
|
|
+ const customerQuestion = message.sender_role === 'customer' && isQuestion(content);
|
|
|
+ if (!customerQuestion) continue;
|
|
|
+ const response = messages.slice(index + 1).find(item => item.sender_role === 'staff' && meaningfulResponse(content, item.content));
|
|
|
+ const elapsed = response ? (new Date(response.sent_at) - new Date(message.sent_at)) / 60000 : (Date.now() - new Date(message.sent_at).getTime()) / 60000;
|
|
|
+ if (!response || elapsed > slaMinutes) findings.push(this.upsertFinding(account, roomId, {
|
|
|
+ fingerprint: `unanswered:${message.external_message_id}`, type: 'unanswered_question', severity: elapsed > slaMinutes * 2 ? 'high' : 'medium',
|
|
|
+ title: '客户问题未在 SLA 内回应', detail: `响应时限 ${slaMinutes} 分钟,当前 ${response ? `用时 ${Math.round(elapsed)} 分钟` : '尚未回应'}`,
|
|
|
+ evidence: [{ messageId: message.external_message_id, content, sentAt: message.sent_at }], confidence: 0.95
|
|
|
+ }, actor));
|
|
|
+ else {
|
|
|
+ const prior = this.db.prepare(`SELECT * FROM group_ops_findings WHERE account_key=? AND room_id=? AND fingerprint=?
|
|
|
+ AND status IN ('open','acknowledged')`).get(account, roomId, `unanswered:${message.external_message_id}`);
|
|
|
+ if (prior) this.updateFinding(account, prior.id, { status: 'resolved', resolution: `检测到 SLA 内有效回复:${response.external_message_id}` }, actor);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ this.audit(account, actor, 'messages.review', 'group', roomId, {}, { messageCount: messages.length, findingCount: findings.length });
|
|
|
+ return { roomId, messageCount: messages.length, findingCount: findings.length, findings };
|
|
|
+ }
|
|
|
+
|
|
|
+ upsertFinding(key, roomId, finding, actor = 'agent') {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const existing = this.db.prepare('SELECT * FROM group_ops_findings WHERE account_key=? AND room_id=? AND fingerprint=?').get(account, roomId, finding.fingerprint);
|
|
|
+ if (existing) return { ...existing, evidence: parse(existing.evidence_json, []) };
|
|
|
+ const findingId = id('gof');
|
|
|
+ const timestamp = now();
|
|
|
+ this.db.prepare('INSERT INTO group_ops_findings VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)')
|
|
|
+ .run(findingId, account, roomId, finding.fingerprint, finding.type, finding.severity || 'medium', finding.title, finding.detail || '',
|
|
|
+ json(finding.evidence || []), 'mvp-1', Number(finding.confidence ?? 1), 'open', '', finding.dueAt || '', '', timestamp, timestamp);
|
|
|
+ const created = this.db.prepare('SELECT * FROM group_ops_findings WHERE id=?').get(findingId);
|
|
|
+ this.audit(account, actor, 'finding.create', 'finding', findingId, {}, created);
|
|
|
+ return { ...created, evidence: parse(created.evidence_json, []) };
|
|
|
+ }
|
|
|
+
|
|
|
+ updateFinding(key, findingId, fields = {}, actor = 'agent') {
|
|
|
+ const before = this.db.prepare('SELECT * FROM group_ops_findings WHERE id=? AND account_key=?').get(findingId, accountKey(key));
|
|
|
+ if (!before) throw new Error('质检项不存在或不属于当前账号');
|
|
|
+ const statuses = ['open', 'acknowledged', 'resolved', 'false_positive'];
|
|
|
+ const status = statuses.includes(fields.status) ? fields.status : before.status;
|
|
|
+ this.db.prepare('UPDATE group_ops_findings SET status=?,assignee=?,resolution=?,updated_at=? WHERE id=?')
|
|
|
+ .run(status, fields.assignee ?? before.assignee, fields.resolution ?? before.resolution, now(), findingId);
|
|
|
+ const after = this.db.prepare('SELECT * FROM group_ops_findings WHERE id=?').get(findingId);
|
|
|
+ if (status === 'false_positive' && before.status !== 'false_positive') {
|
|
|
+ this.db.prepare('INSERT INTO group_ops_feedback VALUES(?,?,?,?,?,?,?)')
|
|
|
+ .run(id('gofb'), accountKey(key), findingId, 'false_positive', fields.resolution || fields.note || '', actor, now());
|
|
|
+ }
|
|
|
+ this.audit(key, actor, 'finding.update', 'finding', findingId, before, after);
|
|
|
+ return after;
|
|
|
+ }
|
|
|
+
|
|
|
+ createTask(key, input = {}, actor = 'agent') {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const title = String(input.title || '').trim();
|
|
|
+ if (!title) throw new Error('整改任务标题不能为空');
|
|
|
+ if (input.findingId) {
|
|
|
+ const finding = this.db.prepare('SELECT * FROM group_ops_findings WHERE id=? AND account_key=?').get(input.findingId, account);
|
|
|
+ if (!finding) throw new Error('关联质检项不存在或不属于当前账号');
|
|
|
+ }
|
|
|
+ const taskId = id('got');
|
|
|
+ const timestamp = now();
|
|
|
+ this.db.prepare('INSERT INTO group_ops_tasks VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)')
|
|
|
+ .run(taskId, account, input.roomId || '', input.findingId || null, title, input.owner || '', input.dueAt || '', input.priority || 'medium', 'open', '', '', timestamp, timestamp);
|
|
|
+ const task = this.getTask(account, taskId);
|
|
|
+ this.audit(account, actor, 'task.create', 'task', taskId, {}, task);
|
|
|
+ return task;
|
|
|
+ }
|
|
|
+
|
|
|
+ getTask(key, taskId) {
|
|
|
+ return this.db.prepare('SELECT * FROM group_ops_tasks WHERE id=? AND account_key=?').get(taskId, accountKey(key)) || null;
|
|
|
+ }
|
|
|
+
|
|
|
+ listTasks(key, status = '') {
|
|
|
+ const account = accountKey(key);
|
|
|
+ return status
|
|
|
+ ? this.db.prepare('SELECT * FROM group_ops_tasks WHERE account_key=? AND status=? ORDER BY due_at,created_at DESC').all(account, status)
|
|
|
+ : this.db.prepare("SELECT * FROM group_ops_tasks WHERE account_key=? ORDER BY CASE status WHEN 'open' THEN 0 WHEN 'in_progress' THEN 1 ELSE 2 END,due_at,created_at DESC").all(account);
|
|
|
+ }
|
|
|
+
|
|
|
+ updateTask(key, taskId, fields = {}, actor = 'agent') {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const before = this.getTask(account, taskId);
|
|
|
+ if (!before) throw new Error('整改任务不存在或不属于当前账号');
|
|
|
+ const status = ['open', 'in_progress', 'done', 'cancelled'].includes(fields.status) ? fields.status : before.status;
|
|
|
+ this.db.prepare(`UPDATE group_ops_tasks SET owner=?,due_at=?,priority=?,status=?,official_todo_id=?,official_sync_status=?,updated_at=? WHERE id=?`)
|
|
|
+ .run(fields.owner ?? before.owner, fields.dueAt ?? before.due_at, fields.priority ?? before.priority, status,
|
|
|
+ fields.officialTodoId ?? before.official_todo_id, fields.officialSyncStatus ?? before.official_sync_status, now(), taskId);
|
|
|
+ const after = this.getTask(account, taskId);
|
|
|
+ this.audit(account, actor, 'task.update', 'task', taskId, before, after);
|
|
|
+ return after;
|
|
|
+ }
|
|
|
+
|
|
|
+ feedbackSummary(key) {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const total = Number(this.db.prepare('SELECT COUNT(*) value FROM group_ops_feedback WHERE account_key=?').get(account)?.value || 0);
|
|
|
+ const falsePositives = Number(this.db.prepare("SELECT COUNT(*) value FROM group_ops_feedback WHERE account_key=? AND label='false_positive'").get(account)?.value || 0);
|
|
|
+ return { total, falsePositives, falsePositiveRate: total ? Math.round(falsePositives / total * 100) : 0,
|
|
|
+ recent: this.db.prepare('SELECT * FROM group_ops_feedback WHERE account_key=? ORDER BY created_at DESC LIMIT 50').all(account) };
|
|
|
+ }
|
|
|
+
|
|
|
+ groupInsights(key, roomId = '') {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const groups = roomId ? [this.getGroup(account, roomId)].filter(Boolean) : this.listGroups(account);
|
|
|
+ const currentStart = new Date(Date.now() - 7 * 86400000).toISOString();
|
|
|
+ const priorStart = new Date(Date.now() - 14 * 86400000).toISOString();
|
|
|
+ return groups.map(group => {
|
|
|
+ const current = this.db.prepare(`SELECT COUNT(*) message_count,COUNT(DISTINCT sender_id) speaker_count,
|
|
|
+ SUM(CASE WHEN sender_role='customer' THEN 1 ELSE 0 END) customer_count,
|
|
|
+ SUM(CASE WHEN sender_role='staff' THEN 1 ELSE 0 END) staff_count,MAX(sent_at) last_message_at
|
|
|
+ FROM group_ops_messages WHERE account_key=? AND room_id=? AND sent_at>=?`).get(account, group.room_id, currentStart);
|
|
|
+ const prior = this.db.prepare('SELECT COUNT(*) message_count FROM group_ops_messages WHERE account_key=? AND room_id=? AND sent_at>=? AND sent_at<?').get(account, group.room_id, priorStart, currentStart);
|
|
|
+ const openFindings = Number(this.db.prepare("SELECT COUNT(*) value FROM group_ops_findings WHERE account_key=? AND room_id=? AND status IN ('open','acknowledged')").get(account, group.room_id)?.value || 0);
|
|
|
+ const lastTime = current.last_message_at ? new Date(current.last_message_at).getTime() : 0;
|
|
|
+ const inactiveDays = lastTime ? Math.floor((Date.now() - lastTime) / 86400000) : null;
|
|
|
+ let suggestion = '保持当前运营节奏';
|
|
|
+ if (inactiveDays === null) suggestion = '同步群消息后再评估生命周期';
|
|
|
+ else if (inactiveDays >= 14) suggestion = '建议进入沉默唤醒阶段';
|
|
|
+ else if (inactiveDays >= 7) suggestion = '建议安排轻量回访或价值内容';
|
|
|
+ else if (openFindings > 0) suggestion = '先处理未闭环服务风险';
|
|
|
+ return { ...group, activity: { currentMessages: Number(current.message_count || 0), priorMessages: Number(prior.message_count || 0),
|
|
|
+ activeSpeakers: Number(current.speaker_count || 0), customerMessages: Number(current.customer_count || 0), staffMessages: Number(current.staff_count || 0),
|
|
|
+ trend: Number(prior.message_count || 0) ? Math.round((Number(current.message_count || 0) - Number(prior.message_count || 0)) / Number(prior.message_count || 1) * 100) : null,
|
|
|
+ lastMessageAt: current.last_message_at || null, inactiveDays }, openFindings, lifecycleSuggestion: suggestion,
|
|
|
+ dataCoverage: Number(current.message_count || 0) ? 100 : 0 };
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ storeBreakdown(key, date) {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const groups = this.listGroups(account);
|
|
|
+ const stores = new Map();
|
|
|
+ for (const group of groups) {
|
|
|
+ const storeId = group.store_id || '未分配门店';
|
|
|
+ if (!stores.has(storeId)) stores.set(storeId, { storeId, groups: 0, dueItems: 0, completedItems: 0, openFindings: 0, highRisk: 0 });
|
|
|
+ stores.get(storeId).groups++;
|
|
|
+ }
|
|
|
+ const itemRows = this.db.prepare(`SELECT COALESCE(g.store_id,'未分配门店') store_id,i.status FROM group_ops_plan_items i
|
|
|
+ JOIN group_ops_plans p ON p.id=i.plan_id LEFT JOIN group_ops_groups g ON g.account_key=p.account_key AND g.room_id=p.room_id
|
|
|
+ WHERE p.account_key=? AND p.plan_date=?`).all(account, date);
|
|
|
+ for (const row of itemRows) {
|
|
|
+ if (!stores.has(row.store_id)) stores.set(row.store_id, { storeId: row.store_id, groups: 0, dueItems: 0, completedItems: 0, openFindings: 0, highRisk: 0 });
|
|
|
+ stores.get(row.store_id).dueItems++;
|
|
|
+ if (row.status === 'sent') stores.get(row.store_id).completedItems++;
|
|
|
+ }
|
|
|
+ const riskRows = this.db.prepare(`SELECT COALESCE(g.store_id,'未分配门店') store_id,f.severity FROM group_ops_findings f
|
|
|
+ LEFT JOIN group_ops_groups g ON g.account_key=f.account_key AND g.room_id=f.room_id
|
|
|
+ WHERE f.account_key=? AND f.status IN ('open','acknowledged')`).all(account);
|
|
|
+ for (const row of riskRows) {
|
|
|
+ if (!stores.has(row.store_id)) stores.set(row.store_id, { storeId: row.store_id, groups: 0, dueItems: 0, completedItems: 0, openFindings: 0, highRisk: 0 });
|
|
|
+ stores.get(row.store_id).openFindings++;
|
|
|
+ if (row.severity === 'high') stores.get(row.store_id).highRisk++;
|
|
|
+ }
|
|
|
+ return [...stores.values()].map(item => ({ ...item, executionRate: item.dueItems ? Math.round(item.completedItems / item.dueItems * 100) : null }))
|
|
|
+ .sort((a, b) => (b.highRisk - a.highRisk) || ((a.executionRate ?? 101) - (b.executionRate ?? 101)));
|
|
|
+ }
|
|
|
+
|
|
|
+ excellentScripts(key) {
|
|
|
+ return this.db.prepare(`SELECT i.final_content content,i.type,COUNT(*) use_count,MAX(i.updated_at) last_used_at
|
|
|
+ FROM group_ops_plan_items i JOIN group_ops_plans p ON p.id=i.plan_id
|
|
|
+ WHERE p.account_key=? AND i.status='sent' AND i.final_content<>'' GROUP BY i.final_content,i.type ORDER BY use_count DESC,last_used_at DESC LIMIT 20`).all(accountKey(key));
|
|
|
+ }
|
|
|
+
|
|
|
+ defaultAutomationPolicy() {
|
|
|
+ return {
|
|
|
+ enabled: false, mode: 'dry_run', allowedGroupIds: [], allowedTypes: ['welcome', 'content'],
|
|
|
+ windows: [{ days: [1, 2, 3, 4, 5], start: '09:00', end: '18:00' }],
|
|
|
+ dailyLimitPerGroup: 1, weeklyLimitPerGroup: 7, consecutiveFailureLimit: 3,
|
|
|
+ grayPercent: 0, requireNoOpenHighRisk: true
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ listAutomationPolicies(key) {
|
|
|
+ return this.db.prepare('SELECT * FROM group_ops_automation_policies WHERE account_key=? ORDER BY version DESC').all(accountKey(key))
|
|
|
+ .map(row => ({ ...row, policy: parse(row.policy_json, {}) }));
|
|
|
+ }
|
|
|
+
|
|
|
+ getActiveAutomationPolicy(key) {
|
|
|
+ return this.listAutomationPolicies(key).find(item => item.status === 'active') || null;
|
|
|
+ }
|
|
|
+
|
|
|
+ createAutomationPolicy(key, input = {}, actor = 'agent') {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const version = Number(this.db.prepare('SELECT MAX(version) value FROM group_ops_automation_policies WHERE account_key=?').get(account)?.value || 0) + 1;
|
|
|
+ const base = this.getActiveAutomationPolicy(account)?.policy || this.defaultAutomationPolicy();
|
|
|
+ const policy = { ...base, ...(input.policy || input) };
|
|
|
+ policy.enabled = policy.enabled === true;
|
|
|
+ policy.mode = ['dry_run', 'review', 'auto'].includes(policy.mode) ? policy.mode : 'dry_run';
|
|
|
+ policy.allowedGroupIds = [...new Set((policy.allowedGroupIds || []).map(String).filter(Boolean))];
|
|
|
+ policy.allowedTypes = [...new Set((policy.allowedTypes || []).map(String).filter(Boolean))];
|
|
|
+ policy.grayPercent = Math.max(0, Math.min(100, Number(policy.grayPercent || 0)));
|
|
|
+ policy.dailyLimitPerGroup = Math.max(1, Number(policy.dailyLimitPerGroup || 1));
|
|
|
+ policy.weeklyLimitPerGroup = Math.max(policy.dailyLimitPerGroup, Number(policy.weeklyLimitPerGroup || 7));
|
|
|
+ policy.consecutiveFailureLimit = Math.max(1, Number(policy.consecutiveFailureLimit || 3));
|
|
|
+ const policyId = id('goap');
|
|
|
+ this.db.prepare('INSERT INTO group_ops_automation_policies VALUES(?,?,?,?,?,?,?,?,?)')
|
|
|
+ .run(policyId, account, version, 'draft', json(policy), input.changeNote || '', actor, now(), null);
|
|
|
+ const created = this.listAutomationPolicies(account).find(item => item.id === policyId);
|
|
|
+ this.audit(account, actor, 'automation_policy.create', 'automation_policy', policyId, {}, created);
|
|
|
+ return created;
|
|
|
+ }
|
|
|
+
|
|
|
+ activateAutomationPolicy(key, policyId, actor = 'agent') {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const target = this.db.prepare('SELECT * FROM group_ops_automation_policies WHERE id=? AND account_key=?').get(policyId, account);
|
|
|
+ if (!target) throw new Error('自动化策略不存在或不属于当前账号');
|
|
|
+ const before = this.getActiveAutomationPolicy(account);
|
|
|
+ const timestamp = now();
|
|
|
+ this.db.prepare("UPDATE group_ops_automation_policies SET status='retired' WHERE account_key=? AND status='active'").run(account);
|
|
|
+ this.db.prepare("UPDATE group_ops_automation_policies SET status='active',activated_at=? WHERE id=?").run(timestamp, policyId);
|
|
|
+ this.db.prepare(`INSERT INTO group_ops_automation_state(account_key,paused,consecutive_failures,breaker_open,last_failure,updated_at)
|
|
|
+ VALUES(?,1,0,0,'',?) ON CONFLICT(account_key) DO UPDATE SET paused=1,updated_at=excluded.updated_at`).run(account, timestamp);
|
|
|
+ const after = this.getActiveAutomationPolicy(account);
|
|
|
+ this.audit(account, actor, 'automation_policy.activate', 'automation_policy', policyId, before || {}, after);
|
|
|
+ return after;
|
|
|
+ }
|
|
|
+
|
|
|
+ getAutomationState(key) {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const row = this.db.prepare('SELECT * FROM group_ops_automation_state WHERE account_key=?').get(account);
|
|
|
+ return row || { account_key: account, paused: 1, consecutive_failures: 0, breaker_open: 0, last_failure: '', updated_at: null };
|
|
|
+ }
|
|
|
+
|
|
|
+ updateAutomationState(key, action, actor = 'agent') {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const before = this.getAutomationState(account);
|
|
|
+ let paused = before.paused; let failures = before.consecutive_failures; let breaker = before.breaker_open; let lastFailure = before.last_failure;
|
|
|
+ if (action === 'pause') paused = 1;
|
|
|
+ else if (action === 'resume') paused = 0;
|
|
|
+ else if (action === 'reset_breaker') { failures = 0; breaker = 0; lastFailure = ''; }
|
|
|
+ else throw new Error('不支持的自动化状态操作');
|
|
|
+ this.db.prepare(`INSERT INTO group_ops_automation_state VALUES(?,?,?,?,?,?) ON CONFLICT(account_key) DO UPDATE SET
|
|
|
+ paused=excluded.paused,consecutive_failures=excluded.consecutive_failures,breaker_open=excluded.breaker_open,last_failure=excluded.last_failure,updated_at=excluded.updated_at`)
|
|
|
+ .run(account, paused, failures, breaker, lastFailure, now());
|
|
|
+ const after = this.getAutomationState(account);
|
|
|
+ this.audit(account, actor, `automation.${action}`, 'automation_state', account, before, after);
|
|
|
+ return after;
|
|
|
+ }
|
|
|
+
|
|
|
+ getPlanItemWithContext(key, itemId) {
|
|
|
+ return this.db.prepare(`SELECT i.*,p.account_key,p.room_id,p.plan_date,g.room_name,g.store_id
|
|
|
+ FROM group_ops_plan_items i JOIN group_ops_plans p ON p.id=i.plan_id
|
|
|
+ LEFT JOIN group_ops_groups g ON g.account_key=p.account_key AND g.room_id=p.room_id
|
|
|
+ WHERE i.id=? AND p.account_key=?`).get(itemId, accountKey(key)) || null;
|
|
|
+ }
|
|
|
+
|
|
|
+ listAutomationCandidates(key, limit = 100) {
|
|
|
+ return this.db.prepare(`SELECT i.*,p.account_key,p.room_id,p.plan_date,g.room_name,g.store_id
|
|
|
+ FROM group_ops_plan_items i JOIN group_ops_plans p ON p.id=i.plan_id
|
|
|
+ LEFT JOIN group_ops_groups g ON g.account_key=p.account_key AND g.room_id=p.room_id
|
|
|
+ WHERE p.account_key=? AND i.status='approved' ORDER BY i.scheduled_at LIMIT ?`).all(accountKey(key), Math.max(1, Math.min(500, Number(limit || 100))));
|
|
|
+ }
|
|
|
+
|
|
|
+ evaluateAutomation(key, item, at = new Date()) {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const policyRecord = this.getActiveAutomationPolicy(account);
|
|
|
+ const state = this.getAutomationState(account);
|
|
|
+ const reasons = [];
|
|
|
+ if (!policyRecord) reasons.push('没有已激活策略');
|
|
|
+ const policy = policyRecord?.policy || this.defaultAutomationPolicy();
|
|
|
+ if (!policy.enabled) reasons.push('策略未启用');
|
|
|
+ if (state.paused) reasons.push('自动化已暂停');
|
|
|
+ if (state.breaker_open) reasons.push('熔断器已打开');
|
|
|
+ if (!item || item.status !== 'approved') reasons.push('计划项未批准');
|
|
|
+ if (item?.scheduled_at && new Date(item.scheduled_at).getTime() > at.getTime()) reasons.push('尚未到计划时间');
|
|
|
+ if (item && !policy.allowedGroupIds.includes(item.room_id)) reasons.push('群不在白名单');
|
|
|
+ if (item && !policy.allowedTypes.includes(item.type)) reasons.push('内容类型不允许自动执行');
|
|
|
+ const grayBucket = item ? parseInt(hash(item.room_id).slice(0, 8), 16) % 100 : 100;
|
|
|
+ if (grayBucket >= policy.grayPercent) reasons.push('未命中灰度范围');
|
|
|
+ const time = shanghaiParts(at);
|
|
|
+ const inWindow = (policy.windows || []).some(window => (window.days || []).map(Number).includes(time.weekday) && time.time >= window.start && time.time <= window.end);
|
|
|
+ if (!inWindow) reasons.push('当前不在允许发送时间窗');
|
|
|
+ if (item && policy.requireNoOpenHighRisk) {
|
|
|
+ const highRisk = Number(this.db.prepare("SELECT COUNT(*) value FROM group_ops_findings WHERE account_key=? AND room_id=? AND severity='high' AND status IN ('open','acknowledged')").get(account, item.room_id)?.value || 0);
|
|
|
+ if (highRisk) reasons.push('群存在未闭环高风险');
|
|
|
+ }
|
|
|
+ const settings = this.getQualitySettings(account);
|
|
|
+ const content = String(item?.final_content || '');
|
|
|
+ if (!content) reasons.push('最终话术为空');
|
|
|
+ if (/\{\{\s*[\w.-]+\s*\}\}/.test(content)) reasons.push('话术仍有未替换变量');
|
|
|
+ if (settings.forbidden_claims.some(term => content.includes(term))) reasons.push('话术命中禁止承诺');
|
|
|
+ if (item) {
|
|
|
+ const duplicate = Number(this.db.prepare("SELECT COUNT(*) value FROM group_ops_send_attempts WHERE account_key=? AND idempotency_key=? AND mode='live' AND status IN ('sending','sent','unknown')").get(account, item.idempotency_key)?.value || 0);
|
|
|
+ if (duplicate) reasons.push('该计划项已有发送中、成功或结果待确认记录');
|
|
|
+ const date = time.date;
|
|
|
+ const weekStart = new Date(at.getTime() - 7 * 86400000).toISOString();
|
|
|
+ const daily = Number(this.db.prepare("SELECT COUNT(*) value FROM group_ops_send_attempts WHERE account_key=? AND room_id=? AND mode='live' AND status='sent' AND substr(created_at,1,10)=?").get(account, item.room_id, date)?.value || 0);
|
|
|
+ const weekly = Number(this.db.prepare("SELECT COUNT(*) value FROM group_ops_send_attempts WHERE account_key=? AND room_id=? AND mode='live' AND status='sent' AND created_at>=?").get(account, item.room_id, weekStart)?.value || 0);
|
|
|
+ if (daily >= policy.dailyLimitPerGroup) reasons.push('达到单群每日频控');
|
|
|
+ if (weekly >= policy.weeklyLimitPerGroup) reasons.push('达到单群每周频控');
|
|
|
+ }
|
|
|
+ return { allowed: reasons.length === 0, reasons, policyId: policyRecord?.id || null, policyVersion: policyRecord?.version || null, mode: policy.mode, grayBucket, evaluatedAt: at.toISOString() };
|
|
|
+ }
|
|
|
+
|
|
|
+ createSendAttempt(key, item, decision, mode) {
|
|
|
+ const attemptId = id('gosa');
|
|
|
+ this.db.prepare('INSERT INTO group_ops_send_attempts VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)')
|
|
|
+ .run(attemptId, accountKey(key), item.id, item.room_id, item.idempotency_key, mode, mode === 'dry_run' ? 'dry_run' : 'sending', json(decision), '', '', decision.policyVersion || null, now(), mode === 'dry_run' ? now() : null);
|
|
|
+ return this.db.prepare('SELECT * FROM group_ops_send_attempts WHERE id=?').get(attemptId);
|
|
|
+ }
|
|
|
+
|
|
|
+ finishSendAttempt(key, attemptId, result = {}) {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const attempt = this.db.prepare('SELECT * FROM group_ops_send_attempts WHERE id=? AND account_key=?').get(attemptId, account);
|
|
|
+ if (!attempt) throw new Error('发送尝试不存在');
|
|
|
+ const status = result.success ? 'sent' : result.ambiguous ? 'unknown' : 'failed';
|
|
|
+ this.db.prepare('UPDATE group_ops_send_attempts SET status=?,external_message_id=?,error=?,completed_at=? WHERE id=?')
|
|
|
+ .run(status, result.externalMessageId || '', result.error || '', now(), attemptId);
|
|
|
+ const active = this.getActiveAutomationPolicy(account);
|
|
|
+ const limit = active?.policy?.consecutiveFailureLimit || 3;
|
|
|
+ const state = this.getAutomationState(account);
|
|
|
+ const failures = result.success ? 0 : Number(state.consecutive_failures || 0) + 1;
|
|
|
+ const breaker = result.success ? 0 : failures >= limit ? 1 : state.breaker_open;
|
|
|
+ this.db.prepare(`INSERT INTO group_ops_automation_state VALUES(?,?,?,?,?,?) ON CONFLICT(account_key) DO UPDATE SET
|
|
|
+ consecutive_failures=excluded.consecutive_failures,breaker_open=excluded.breaker_open,last_failure=excluded.last_failure,updated_at=excluded.updated_at`)
|
|
|
+ .run(account, state.paused, failures, breaker, result.success ? '' : (result.error || '发送失败'), now());
|
|
|
+ this.audit(account, 'automation', `send.${status}`, 'send_attempt', attemptId, attempt, { ...result, breakerOpen: Boolean(breaker) });
|
|
|
+ return this.db.prepare('SELECT * FROM group_ops_send_attempts WHERE id=?').get(attemptId);
|
|
|
+ }
|
|
|
+
|
|
|
+ automationOverview(key) {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const candidates = this.listAutomationCandidates(account);
|
|
|
+ return { activePolicy: this.getActiveAutomationPolicy(account), policies: this.listAutomationPolicies(account), state: this.getAutomationState(account), liveSendEnabled: process.env.QIWEI_GROUP_OPS_LIVE_SEND === '1',
|
|
|
+ candidates: candidates.map(item => ({ item, decision: this.evaluateAutomation(account, item) })),
|
|
|
+ attempts: this.db.prepare('SELECT * FROM group_ops_send_attempts WHERE account_key=? ORDER BY created_at DESC LIMIT 100').all(account) };
|
|
|
+ }
|
|
|
+
|
|
|
+ calculateDailyScores(key, date = new Date().toISOString().slice(0, 10)) {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const weights = { sop: 30, response: 30, risk: 25, engagement: 15 };
|
|
|
+ const rows = [];
|
|
|
+ for (const group of this.listGroups(account)) {
|
|
|
+ const items = this.db.prepare(`SELECT i.status FROM group_ops_plan_items i JOIN group_ops_plans p ON p.id=i.plan_id
|
|
|
+ WHERE p.account_key=? AND p.room_id=? AND p.plan_date=?`).all(account, group.room_id, date);
|
|
|
+ const messageStats = this.db.prepare(`SELECT COUNT(*) total,COUNT(DISTINCT CASE WHEN sender_id<>'' THEN sender_id END) speakers,
|
|
|
+ SUM(CASE WHEN sender_role='customer' THEN 1 ELSE 0 END) customers FROM group_ops_messages
|
|
|
+ WHERE account_key=? AND room_id=? AND substr(sent_at,1,10)=?`).get(account, group.room_id, date);
|
|
|
+ const questions = this.db.prepare(`SELECT content FROM group_ops_messages WHERE account_key=? AND room_id=?
|
|
|
+ AND sender_role='customer' AND substr(sent_at,1,10)=?`).all(account, group.room_id, date).filter(row => isQuestion(row.content)).length;
|
|
|
+ const unanswered = Number(this.db.prepare(`SELECT COUNT(*) value FROM group_ops_findings WHERE account_key=? AND room_id=?
|
|
|
+ AND type='unanswered_question' AND status IN ('open','acknowledged')
|
|
|
+ AND substr(COALESCE(json_extract(evidence_json,'$[0].sentAt'),created_at),1,10)=?`).get(account, group.room_id, date)?.value || 0);
|
|
|
+ const risks = this.db.prepare(`SELECT severity,COUNT(*) count FROM group_ops_findings WHERE account_key=? AND room_id=?
|
|
|
+ AND type='service_risk' AND status IN ('open','acknowledged')
|
|
|
+ AND substr(COALESCE(json_extract(evidence_json,'$[0].sentAt'),created_at),1,10)=? GROUP BY severity`).all(account, group.room_id, date);
|
|
|
+ const riskPenalty = risks.reduce((sum, row) => sum + Number(row.count) * (row.severity === 'high' ? 30 : row.severity === 'medium' ? 15 : 5), 0);
|
|
|
+ const components = {
|
|
|
+ sop: items.length ? { available: true, score: Math.round(items.filter(item => item.status === 'sent').length / items.length * 100), evidenceCount: items.length } : { available: false, score: null, evidenceCount: 0 },
|
|
|
+ response: questions ? { available: true, score: Math.max(0, Math.round((questions - unanswered) / questions * 100)), evidenceCount: questions } : { available: false, score: null, evidenceCount: 0 },
|
|
|
+ risk: Number(messageStats.total) ? { available: true, score: Math.max(0, 100 - riskPenalty), evidenceCount: Number(messageStats.total) } : { available: false, score: null, evidenceCount: 0 },
|
|
|
+ engagement: Number(messageStats.total) ? { available: true, score: Math.min(100, Math.round(Number(messageStats.speakers || 0) * 20 + Number(messageStats.customers || 0) * 5)), evidenceCount: Number(messageStats.total) } : { available: false, score: null, evidenceCount: 0 }
|
|
|
+ };
|
|
|
+ const available = Object.entries(components).filter(([, component]) => component.available);
|
|
|
+ const effectiveWeight = available.reduce((sum, [name]) => sum + weights[name], 0);
|
|
|
+ const totalScore = effectiveWeight ? Math.round(available.reduce((sum, [name, component]) => sum + component.score * weights[name], 0) / effectiveWeight) : null;
|
|
|
+ const coverage = Math.round(effectiveWeight);
|
|
|
+ const timestamp = now();
|
|
|
+ this.db.prepare(`INSERT INTO group_ops_daily_scores VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(account_key,room_id,score_date) DO UPDATE SET
|
|
|
+ component_json=excluded.component_json,coverage=excluded.coverage,total_score=excluded.total_score,updated_at=excluded.updated_at`)
|
|
|
+ .run(account, group.room_id, date, json(components), coverage, totalScore, timestamp, timestamp);
|
|
|
+ rows.push({ accountKey: account, roomId: group.room_id, roomName: group.room_name, storeId: group.store_id, date, components, coverage, totalScore });
|
|
|
+ }
|
|
|
+ return rows;
|
|
|
+ }
|
|
|
+
|
|
|
+ overview(key, date = new Date().toISOString().slice(0, 10)) {
|
|
|
+ const account = accountKey(key);
|
|
|
+ const one = (sql, ...params) => Number(this.db.prepare(sql).get(...params)?.value || 0);
|
|
|
+ const groups = this.listGroups(account);
|
|
|
+ const plans = this.db.prepare('SELECT * FROM group_ops_plans WHERE account_key=? AND plan_date=? ORDER BY updated_at DESC').all(account, date).map(plan => this.getPlan(plan.id));
|
|
|
+ const findings = this.db.prepare("SELECT * FROM group_ops_findings WHERE account_key=? AND status IN ('open','acknowledged') ORDER BY CASE severity WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END,created_at DESC LIMIT 100").all(account).map(row => ({ ...row, evidence: parse(row.evidence_json, []) }));
|
|
|
+ const allItems = plans.flatMap(plan => plan.items);
|
|
|
+ const completed = allItems.filter(item => item.status === 'sent').length;
|
|
|
+ const scores = this.calculateDailyScores(account, date);
|
|
|
+ const scored = scores.filter(item => item.totalScore !== null);
|
|
|
+ const healthDistribution = {
|
|
|
+ healthy: scored.filter(item => item.totalScore >= 80).length,
|
|
|
+ attention: scored.filter(item => item.totalScore >= 60 && item.totalScore < 80).length,
|
|
|
+ risk: scored.filter(item => item.totalScore < 60).length,
|
|
|
+ unavailable: scores.length - scored.length
|
|
|
+ };
|
|
|
+ return {
|
|
|
+ date, metrics: { groups: groups.length, playbooks: one("SELECT COUNT(*) value FROM group_ops_playbooks WHERE account_key=? AND status='published'", account),
|
|
|
+ dueItems: allItems.length, completedItems: completed, pendingReview: allItems.filter(item => item.status === 'pending_review').length,
|
|
|
+ openFindings: findings.length, highRisk: findings.filter(item => item.severity === 'high').length,
|
|
|
+ openTasks: one("SELECT COUNT(*) value FROM group_ops_tasks WHERE account_key=? AND status IN ('open','in_progress')", account),
|
|
|
+ executionRate: allItems.length ? Math.round(completed / allItems.length * 100) : null,
|
|
|
+ averageHealthScore: scored.length ? Math.round(scored.reduce((sum, item) => sum + item.totalScore, 0) / scored.length) : null,
|
|
|
+ averageHealthCoverage: scores.length ? Math.round(scores.reduce((sum, item) => sum + item.coverage, 0) / scores.length) : 0,
|
|
|
+ healthDistribution },
|
|
|
+ groups, plans, findings, tasks: this.listTasks(account), stores: this.storeBreakdown(account, date),
|
|
|
+ insights: this.groupInsights(account), qualitySettings: this.getQualitySettings(account), feedback: this.feedbackSummary(account),
|
|
|
+ excellentScripts: this.excellentScripts(account), automation: this.automationOverview(account), scores
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ close() { this.db.close(); }
|
|
|
+}
|
|
|
+
|
|
|
+let singleton;
|
|
|
+function getGroupOperationsStore() { if (!singleton) singleton = new GroupOperationsStore(); return singleton; }
|
|
|
+
|
|
|
+module.exports = { GroupOperationsStore, getGroupOperationsStore, accountKey };
|