docs: 新增智脑实施方法论文档体系(7篇) + fix: 态势感知关联分析修复

文档:
- 01-项目概述与架构: 技术架构、数据流、部署拓扑
- 02-数据接入与治理: 原始数据导入、物化视图、门店名映射
- 03-指标体系与API开发: 指标分层、SQL模式、常见陷阱
- 04-前端页面开发: 组件规范、页面模板、月份参数管理
- 05-部署与运维: 部署脚本、FRP隧道、PM2、备份
- 06-调试排查手册: 问题分类、8个实际案例、工具速查
- 07-通用方法论: 核心原则、实施阶段、快速复制Checklist

修复:
- situational-awareness.ts: salary_month→salary_period, 日期格式改中文
- 客流-人力匹配: 改用attendance_records打卡数据解析在岗人数
- 客流数据除以30天对齐日均
This commit is contained in:
freedakgmail
2026-08-12 11:00:54 +08:00
parent 2c5b03e41b
commit c2a9e27e49
9 changed files with 1066 additions and 30 deletions
+128 -30
View File
@@ -266,38 +266,136 @@ router.get('/correlation', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
// 客流-人力匹配度:每门店每小时"每人在岗产出账单数"
// mv_store_hourly_staffing 可能不存在,容错处理
// 从 attendance_records 打卡记录解析每门店每小时实际在岗人数
let staffingRows: any[] = []
try {
const staffingEfficiency = await query(`
WITH hourly_bills AS (
SELECT store_name, hour, sum(bills) AS bills
FROM mv_bill_hourly
GROUP BY store_name, hour
),
hourly_staff AS (
SELECT store_name, hour, total_staff
FROM mv_store_hourly_staffing
WHERE total_staff > 0
)
SELECT
COALESCE(b.store_name, s.store_name) AS store_name,
COALESCE(b.hour, s.hour) AS hour,
COALESCE(b.bills, 0) AS bills,
COALESCE(s.total_staff, 0) AS staff,
round(COALESCE(b.bills, 0)::numeric / nullif(COALESCE(s.total_staff, 0), 0), 1) AS bills_per_staff,
CASE
WHEN COALESCE(s.total_staff, 0) = 0 THEN '无在岗数据'
WHEN COALESCE(b.bills, 0) > 0 AND COALESCE(b.bills, 0)::numeric / COALESCE(s.total_staff, 0) > 15 THEN '高峰人手不足'
WHEN COALESCE(b.bills, 0) = 0 AND COALESCE(s.total_staff, 0) > 3 THEN '低谷人员冗余'
ELSE '配置合理'
END AS match_status
FROM hourly_bills b
FULL OUTER JOIN hourly_staff s ON b.store_name = s.store_name AND b.hour = s.hour
WHERE COALESCE(b.bills, 0) > 0 OR COALESCE(s.total_staff, 0) > 0
ORDER BY COALESCE(b.store_name, s.store_name), COALESCE(b.hour, s.hour)
// 1. 取打卡记录
const attResult = await query(`
SELECT department, position, day_01, day_02, day_03, day_04, day_05, day_06, day_07,
day_08, day_09, day_10, day_11, day_12, day_13, day_14, day_15, day_16, day_17,
day_18, day_19, day_20, day_21, day_22, day_23, day_24, day_25, day_26, day_27,
day_28, day_29, day_30, day_31
FROM attendance_records
WHERE department LIKE '%西部马华品牌门店%'
`)
staffingRows = staffingEfficiency.rows
// 2. 解析打卡时间,汇总 store -> hour -> count
function extractStore(dept: string): string {
const parts = dept.split('/')
for (let i = parts.length - 1; i >= 0; i--) {
if (parts[i].endsWith('店')) return parts[i]
}
return ''
}
function parseClockTimes(raw: string): { start: number; end: number } | null {
const times = raw.match(/(\d{1,2}):(\d{2})/g)
if (!times || times.length < 2) return null
const startParts = times[0].match(/(\d{1,2}):(\d{2})/)
const endParts = times[times.length - 1].match(/(\d{1,2}):(\d{2})/)
if (!startParts || !endParts) return null
const startHour = parseInt(startParts[1])
let endHour = parseInt(endParts[1])
if (endHour < startHour) endHour = 23
return { start: startHour, end: endHour }
}
const staffingMap: Record<string, Record<number, number>> = {}
const dayFields = ['day_01','day_02','day_03','day_04','day_05','day_06','day_07',
'day_08','day_09','day_10','day_11','day_12','day_13','day_14','day_15','day_16','day_17',
'day_18','day_19','day_20','day_21','day_22','day_23','day_24','day_25','day_26','day_27',
'day_28','day_29','day_30','day_31']
for (const row of attResult.rows) {
const store = extractStore(row.department as string)
if (!store) continue
if (!staffingMap[store]) staffingMap[store] = {}
for (const day of dayFields) {
const raw = (row as any)[day] as string
if (!raw || raw === '') continue
const clock = parseClockTimes(raw)
if (!clock) continue
// 每天打卡算1人在岗,按小时累计(取所有天的平均)
for (let h = clock.start; h <= clock.end; h++) {
staffingMap[store][h] = (staffingMap[store][h] || 0) + 1
}
}
}
// 3. 计算每店每天平均在岗人数(总打卡人天 / 30天)
const staffSummary: Record<string, Record<number, number>> = {}
for (const [store, hours] of Object.entries(staffingMap)) {
staffSummary[store] = {}
for (let h = 0; h < 24; h++) {
// 除以30天得到日均同时在岗人数
staffSummary[store][h] = Math.round((hours[h] || 0) / 30)
}
}
// 4. 映射表
const mappingResult = await query(`SELECT salary_name, bill_name FROM public.store_name_mapping`)
const nameMap: Record<string, string> = {}
for (const r of mappingResult.rows) {
nameMap[r.salary_name as string] = r.bill_name as string
}
// 5. 取客流数据
const hourlyResult = await query(`
SELECT store_name, hour, sum(bills) AS bills
FROM mv_bill_hourly
GROUP BY store_name, hour
`)
// 6. 构建门店→在岗人数的统一查找表(同时用打卡名和映射名)
const staffLookup: Record<string, Record<number, number>> = {}
for (const [store, hours] of Object.entries(staffSummary)) {
staffLookup[store] = hours
const mapped = nameMap[store]
if (mapped && mapped !== store) {
staffLookup[mapped] = hours
}
}
// 7. 构建输出
const billMap: Record<string, any[]> = {}
for (const r of hourlyResult.rows) {
const sn = r.store_name as string
if (!billMap[sn]) billMap[sn] = []
billMap[sn].push(r)
}
const allStores = new Set<string>(Object.keys(billMap))
for (const s of Object.keys(staffLookup)) allStores.add(s)
const output: any[] = []
for (const store of allStores) {
const staffHours = staffLookup[store] || null
const storeHours = billMap[store] || []
if (storeHours.length === 0 && !staffHours) continue
if (storeHours.length > 0) {
const minHour = Math.min(...storeHours.map((r: any) => r.hour))
const maxHour = Math.max(...storeHours.map((r: any) => r.hour))
for (let h = minHour; h <= maxHour; h++) {
const rawBills = (storeHours.find((r: any) => r.hour === h)?.bills as string) || '0'
const billsNum = Math.round(parseInt(rawBills) / 30)
const staff = staffHours ? staffHours[h] || 0 : 0
const ratio = staff > 0 ? billsNum / staff : null
output.push({
store_name: store,
hour: h,
bills: String(billsNum),
staff: staff,
bills_per_staff: ratio ? Math.round(ratio * 10) / 10 : null,
match_status: staff === 0 ? '无在岗数据'
: billsNum > 0 && ratio! > 15 ? '高峰人手不足'
: billsNum === 0 && staff > 3 ? '低谷人员冗余'
: '配置合理'
})
}
}
}
output.sort((a, b) => a.store_name.localeCompare(b.store_name) || a.hour - b.hour)
staffingRows = output
} catch {
staffingRows = []
}
@@ -371,7 +469,7 @@ router.get('/correlation', async (req: AuthRequest, res) => {
round(count(*) FILTER (WHERE s.actual_attend / nullif(s.expected_attend, 0) < 0.8)::numeric / nullif(count(*), 0) * 100, 1) AS low_attend_rate
FROM salary_detail_records s
WHERE s.org_level2 = '西部马华品牌门店' AND s.org_level5 IS NOT NULL AND s.org_level5 != ''
AND s.salary_month = to_char($1::date, 'YYYY-MM')
AND s.salary_period = to_char($1::date, 'YYYY"年"FMMM"月"')
GROUP BY s.org_level5
),
rev AS (