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:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user