'use strict'; const db = require('../infra/db'); const sync = require('../sync/sync'); /** 与前端 export-employees.js 保持一致:六维相加为月画像分,累计取各月平均 */ const FORMULA_VERSION = 'v3'; const BASE = { km: 15, performance: 25, online: 15, practical: 15, fault: 10, incident: 10, }; let schemaReady = false; function normalizeEmployeeId(value) { const raw = String(value || '').trim(); if (!raw) return ''; return raw.replace(/^0+/, '') || '0'; } function padEmployeeId(value) { const raw = String(value || '').trim(); if (!raw) return ''; if (/^\d+$/.test(raw) && raw.length <= 5) return raw.padStart(5, '0'); return raw; } function idVariants(employeeId) { return [...new Set([ String(employeeId || '').trim(), padEmployeeId(employeeId), normalizeEmployeeId(employeeId), ].filter(Boolean))]; } function parseEmployeeIds(value) { if (Array.isArray(value)) { return value.map((item) => normalizeEmployeeId(item)).filter(Boolean); } if (value == null || value === '') return []; if (typeof value === 'string') { const text = value.trim(); if (!text) return []; try { const parsed = JSON.parse(text); if (Array.isArray(parsed)) { return parsed.map((item) => normalizeEmployeeId(item)).filter(Boolean); } } catch (_error) { /* ignore */ } return text.split(/[,,;\s]+/).map((item) => normalizeEmployeeId(item)).filter(Boolean); } if (Buffer.isBuffer(value)) return parseEmployeeIds(value.toString('utf8')); return [normalizeEmployeeId(value)].filter(Boolean); } function isFaultSuccess(value) { return String(value ?? '').includes('成功'); } function hasIncidentIssue(value) { const text = String(value ?? '').trim(); if (!text) return false; if (text === '/' || text === '-' || text === '无' || text === '无问题') return false; return true; } function drivePointsValue(value) { const num = Number(value); return Number.isFinite(num) ? num : 0; } function round1(value) { return Math.round(Number(value) * 10) / 10; } function ymKey(year, month) { return `${Number(year)}-${String(Number(month)).padStart(2, '0')}`; } function parseYm(value) { if (!value) return null; if (value instanceof Date && !Number.isNaN(value.getTime())) { return { year: value.getFullYear(), month: value.getMonth() + 1 }; } const text = String(value).trim(); const match = text.match(/(\d{4})\D+(\d{1,2})/); if (!match) return null; const year = Number(match[1]); const month = Number(match[2]); if (!Number.isFinite(year) || month < 1 || month > 12) return null; return { year, month }; } /** * 业务月:本月 21 日~下月 20 日归入「下月」(跨年进次年 1 月)。 * 仅用于绩效考核/嘉奖 assessment_date。 * 例:2025-12-21 → 2026-01;2025-12-20 → 2025-12。 */ function calendarPartsShanghai(value) { if (value == null || value === '') return null; if (typeof value === 'string') { const dateOnly = value.match(/^(\d{4})-(\d{2})-(\d{2})/); if (dateOnly) { return { year: Number(dateOnly[1]), month: Number(dateOnly[2]), day: Number(dateOnly[3]), }; } } const date = value instanceof Date ? value : new Date(value); if (Number.isNaN(date.getTime())) return null; const formatted = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', }).format(date); const matched = formatted.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!matched) return null; return { year: Number(matched[1]), month: Number(matched[2]), day: Number(matched[3]), }; } function parseBusinessYm(value) { const parts = calendarPartsShanghai(value); if (!parts) return null; let { year, month, day } = parts; if (day >= 21) { month += 1; if (month > 12) { month = 1; year += 1; } } if (!Number.isFinite(year) || month < 1 || month > 12) return null; return { year, month }; } /** 自然月:按故障/事故发生日期的日历年月归桶(上海时区) */ function parseCalendarYm(value) { const parts = calendarPartsShanghai(value); if (!parts) return parseYm(value); return { year: parts.year, month: parts.month }; } function monthRange(start, end) { const out = []; if (!start || !end) return out; let y = start.year; let m = start.month; const endKey = ymKey(end.year, end.month); while (ymKey(y, m) <= endKey) { out.push({ year: y, month: m }); m += 1; if (m > 12) { m = 1; y += 1; } } return out; } function earlierYm(a, b) { if (!a) return b; if (!b) return a; return ymKey(a.year, a.month) <= ymKey(b.year, b.month) ? a : b; } function extractRemarks(details) { if (details == null || details === '') return ''; let obj = details; if (typeof details === 'string') { try { obj = JSON.parse(details); } catch (_e) { return ''; } } if (Buffer.isBuffer(details)) { try { obj = JSON.parse(details.toString('utf8')); } catch (_e) { return ''; } } return String(obj?.remarks || '').trim(); } /** * 备注含「组长」(含后备班组长、组长/施工负责人等)→ 公里分可按班组均值保底。 * 仅写「施工负责人」、不含「组长」的不适用。 */ function usesTeamAvgKm(remarks) { const text = String(remarks || '').trim(); if (!text) return false; return text.includes('组长'); } function recalcTotalScore(metrics) { return round1( (Number(metrics.kmScore) || 0) + (Number(metrics.performanceScore) || 0) + (Number(metrics.onlineExam) || 0) + (Number(metrics.practicalExam) || 0) + (Number(metrics.faultScore) || 0) + (Number(metrics.incidentScore) || 0), ); } function computeMonthScores(parts) { const kmAnomalyCount = Number(parts.kmAnomalyCount) || 0; // 无公里数据按 0 分;有数据则满分 15,异常一次扣 5 const kmScore = parts.hasKm ? Math.max(0, round1(BASE.km - 5 * kmAnomalyCount)) : 0; const performanceDeduction = Number(parts.performanceDeduction) || 0; const drivingDeduction = Number(parts.drivingDeduction) || 0; const performanceScore = Math.max( 0, round1((BASE.performance - performanceDeduction) * (1 - 0.1 * drivingDeduction)), ); // 有成绩:均分×0.15;缺考且无成绩:0;该月无考试记录:基础 15 let onlineExam = BASE.online; if (parts.hasOnline) { onlineExam = Math.max(0, round1(Number(parts.onlineAvg) * 0.15)); } else if (parts.hasMissingOnline) { onlineExam = 0; } const practicalExam = parts.hasPractical ? Math.max(0, round1(Number(parts.practicalAvg) * 0.15)) : BASE.practical; let faultScore = BASE.fault; const faultSuccessCount = Number(parts.faultSuccessCount) || 0; const faultTotalCount = Number(parts.faultTotalCount) || 0; if (faultTotalCount > 0) { faultScore = Math.max(0, round1(BASE.fault + 2 * faultSuccessCount)); } let incidentScore = BASE.incident; const incidentOkCount = Number(parts.incidentOkCount) || 0; const incidentIssueCount = Number(parts.incidentIssueCount) || 0; const incidentTotalCount = incidentOkCount + incidentIssueCount; if (incidentTotalCount > 0) { incidentScore = Math.max( 0, round1(BASE.incident + 5 * incidentOkCount - 10 * incidentIssueCount), ); } const totalScore = round1( kmScore + performanceScore + onlineExam + practicalExam + faultScore + incidentScore, ); return { kmScore, kmAnomalyCount, performanceScore, performanceDeduction, drivingDeduction, onlineExam, practicalExam, faultScore, faultSuccessCount, incidentScore, incidentOkCount, incidentIssueCount, totalScore, }; } async function ensureSchema() { if (schemaReady) return; await db.execute(` CREATE TABLE IF NOT EXISTS crew_metrics_employee_yearly ( id BIGINT AUTO_INCREMENT PRIMARY KEY, year INT NOT NULL, employee_id VARCHAR(20) NOT NULL, km_score DECIMAL(8,2) NOT NULL DEFAULT 12, km_anomaly_count INT NOT NULL DEFAULT 0, performance_score DECIMAL(8,2) NOT NULL DEFAULT 100, performance_deduction DECIMAL(10,2) NOT NULL DEFAULT 0, driving_score DECIMAL(8,2) NOT NULL DEFAULT 12, driving_deduction DECIMAL(10,2) NOT NULL DEFAULT 0, online_exam DECIMAL(8,2) NOT NULL DEFAULT 0, practical_exam DECIMAL(8,2) NOT NULL DEFAULT 0, fault_score DECIMAL(8,2) NOT NULL DEFAULT 80, incident_score DECIMAL(8,2) NOT NULL DEFAULT 100, total_score DECIMAL(8,2) NOT NULL DEFAULT 0, formula_version VARCHAR(16) NOT NULL DEFAULT 'v1', computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, version BIGINT NOT NULL DEFAULT 0, UNIQUE KEY uk_employee_year (employee_id, year), KEY idx_year_total (year, total_score) ) ENGINE=InnoDB `); await db.execute(` CREATE TABLE IF NOT EXISTS crew_metrics_employee_monthly ( id BIGINT AUTO_INCREMENT PRIMARY KEY, year INT NOT NULL, month TINYINT NOT NULL, employee_id VARCHAR(20) NOT NULL, km_score DECIMAL(8,2) NOT NULL DEFAULT 15, km_anomaly_count INT NOT NULL DEFAULT 0, performance_score DECIMAL(8,2) NOT NULL DEFAULT 25, performance_deduction DECIMAL(10,2) NOT NULL DEFAULT 0, driving_deduction DECIMAL(10,2) NOT NULL DEFAULT 0, online_exam DECIMAL(8,2) NOT NULL DEFAULT 15, practical_exam DECIMAL(8,2) NOT NULL DEFAULT 15, fault_score DECIMAL(8,2) NOT NULL DEFAULT 10, fault_success_count INT NOT NULL DEFAULT 0, incident_score DECIMAL(8,2) NOT NULL DEFAULT 10, incident_ok_count INT NOT NULL DEFAULT 0, incident_issue_count INT NOT NULL DEFAULT 0, total_score DECIMAL(8,2) NOT NULL DEFAULT 100, formula_version VARCHAR(16) NOT NULL DEFAULT 'v2', computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, version BIGINT NOT NULL DEFAULT 0, UNIQUE KEY uk_employee_year_month (employee_id, year, month), KEY idx_year_month_total (year, month, total_score), KEY idx_employee_total (employee_id, total_score) ) ENGINE=InnoDB `); await db.execute( `INSERT IGNORE INTO sync_table_versions (table_name, version) VALUES ('crew_metrics_employee_yearly', 0), ('crew_metrics_employee_monthly', 0)`, ); schemaReady = true; } function rowMatchesEmployee(ids, targetVariants) { const normalizedTargets = new Set(targetVariants.map(normalizeEmployeeId)); return ids.some((id) => normalizedTargets.has(id)); } function pushBucket(map, year, month, init) { const key = ymKey(year, month); if (!map.has(key)) map.set(key, typeof init === 'function' ? init() : { ...init }); return map.get(key); } async function loadEmployeeContext(employeeId) { const variants = idVariants(employeeId); const canonicalId = padEmployeeId(employeeId) || String(employeeId).trim(); const placeholders = variants.map(() => '?').join(','); if (!placeholders) { return { canonicalId, variants, hireYm: null, team: '', remarks: '', useTeamAvgKm: false, kmByMonth: new Map(), perfByMonth: new Map(), onlineByMonth: new Map(), onlineMissingByMonth: new Set(), practicalByMonth: new Map(), faultByMonth: new Map(), incidentByMonth: new Map(), earliestYm: null, }; } const [ employeeRows, kmRows, kmSubsidyRows, perfRows, onlineRows, missingRows, practicalRows, faultRows, incidentRows, ] = await Promise.all([ db.query( `SELECT hire_date, team, details FROM employees WHERE deleted_at IS NULL AND employee_id IN (${placeholders}) AND (employment_status = '在职' OR employment_status IS NULL) LIMIT 1`, variants, ), db.query( `SELECT year, month, is_anomaly FROM kilometers_control_stats_monthly WHERE employee_id IN (${placeholders})`, variants, ), db.query( `SELECT year, month, MAX(COALESCE(has_subsidy, 0)) AS has_subsidy FROM kilometers_records WHERE employee_id IN (${placeholders}) GROUP BY year, month`, variants, ).catch(() => []), db.query( `SELECT assessment_date, performance_points, twelve_point_deduction FROM performance_assessments WHERE deleted_at IS NULL AND employee_id IN (${placeholders})`, variants, ), db.query( `SELECT exam_year, exam_month, score, details FROM online_exams WHERE employee_id IN (${placeholders})`, variants, ), db.query( `SELECT missing_month FROM missing_exams WHERE employee_id IN (${placeholders})`, variants, ).catch(() => []), db.query( `SELECT exam_year, exam_month, score FROM practical_exams WHERE employee_id IN (${placeholders})`, variants, ), db.query( `SELECT fault_date, final_result, employee_ids FROM fault_disposals WHERE deleted_at IS NULL`, ), db.query( `SELECT event_date, operation_issues, employee_ids FROM incident_events WHERE deleted_at IS NULL`, ), ]); const hireYm = parseYm(employeeRows[0]?.hire_date); const team = String(employeeRows[0]?.team || '').trim(); const remarks = extractRemarks(employeeRows[0]?.details); let earliestYm = hireYm; const subsidyByMonth = new Map(); for (const row of kmSubsidyRows || []) { const year = Number(row.year); const month = Number(row.month); if (!Number.isFinite(year) || month < 1 || month > 12) continue; subsidyByMonth.set(ymKey(year, month), Number(row.has_subsidy) === 1); } // 公里「有数据」以 kilometers_records 为准;异常次数来自控制图。 // 若控制图被算空/失败,不能把全员公里分打成 0。 const kmByMonth = new Map(); for (const row of kmSubsidyRows || []) { const year = Number(row.year); const month = Number(row.month); if (!Number.isFinite(year) || month < 1 || month > 12) continue; const key = ymKey(year, month); const bucket = pushBucket(kmByMonth, year, month, () => ({ anomalyCount: 0, hasSubsidy: false, })); if (subsidyByMonth.get(key)) bucket.hasSubsidy = true; earliestYm = earlierYm(earliestYm, { year, month }); } for (const row of kmRows || []) { const year = Number(row.year); const month = Number(row.month); if (!Number.isFinite(year) || month < 1 || month > 12) continue; const key = ymKey(year, month); const bucket = pushBucket(kmByMonth, year, month, () => ({ anomalyCount: 0, hasSubsidy: Boolean(subsidyByMonth.get(key)), })); if (Number(row.is_anomaly) === 1) bucket.anomalyCount += 1; if (subsidyByMonth.get(key)) bucket.hasSubsidy = true; earliestYm = earlierYm(earliestYm, { year, month }); } const perfByMonth = new Map(); for (const row of perfRows || []) { // 绩效/嘉奖按业务月归桶:本月21日~下月20日 → 下月 const ym = parseBusinessYm(row.assessment_date); if (!ym) continue; const bucket = pushBucket(perfByMonth, ym.year, ym.month, () => ({ performanceDeduction: 0, drivingDeduction: 0, })); bucket.performanceDeduction += Number(row.performance_points) || 0; bucket.drivingDeduction += drivePointsValue(row.twelve_point_deduction); earliestYm = earlierYm(earliestYm, ym); } const onlineByMonth = new Map(); const onlineMissingByMonth = new Set(); for (const row of onlineRows || []) { const year = Number(row.exam_year); const month = Number(row.exam_month); if (!Number.isFinite(year) || month < 1 || month > 12) continue; let details = row.details; if (typeof details === 'string') { try { details = JSON.parse(details); } catch (_e) { details = {}; } } if (details && details.isMissingExam === true) { onlineMissingByMonth.add(ymKey(year, month)); earliestYm = earlierYm(earliestYm, { year, month }); continue; } if (!Number.isFinite(Number(row.score))) continue; const bucket = pushBucket(onlineByMonth, year, month, () => ({ scores: [] })); bucket.scores.push(Number(row.score)); earliestYm = earlierYm(earliestYm, { year, month }); } for (const row of missingRows || []) { const ym = parseYm(row.missing_month); if (!ym) continue; onlineMissingByMonth.add(ymKey(ym.year, ym.month)); earliestYm = earlierYm(earliestYm, ym); } const practicalByMonth = new Map(); for (const row of practicalRows || []) { const year = Number(row.exam_year); const month = Number(row.exam_month); if (!Number.isFinite(year) || month < 1 || month > 12) continue; if (!Number.isFinite(Number(row.score))) continue; const bucket = pushBucket(practicalByMonth, year, month, () => ({ scores: [] })); bucket.scores.push(Number(row.score)); earliestYm = earlierYm(earliestYm, { year, month }); } const faultByMonth = new Map(); for (const row of faultRows || []) { const ids = parseEmployeeIds(row.employee_ids); if (!rowMatchesEmployee(ids, variants)) continue; const ym = parseCalendarYm(row.fault_date); if (!ym) continue; const bucket = pushBucket(faultByMonth, ym.year, ym.month, () => ({ total: 0, success: 0, })); bucket.total += 1; if (isFaultSuccess(row.final_result)) bucket.success += 1; earliestYm = earlierYm(earliestYm, ym); } const incidentByMonth = new Map(); for (const row of incidentRows || []) { const ids = parseEmployeeIds(row.employee_ids); if (!rowMatchesEmployee(ids, variants)) continue; const ym = parseCalendarYm(row.event_date); if (!ym) continue; const bucket = pushBucket(incidentByMonth, ym.year, ym.month, () => ({ ok: 0, issue: 0, })); if (hasIncidentIssue(row.operation_issues)) bucket.issue += 1; else bucket.ok += 1; earliestYm = earlierYm(earliestYm, ym); } return { canonicalId, variants, hireYm, team, remarks, useTeamAvgKm: usesTeamAvgKm(remarks), kmByMonth, perfByMonth, onlineByMonth, onlineMissingByMonth, practicalByMonth, faultByMonth, incidentByMonth, earliestYm, }; } function resolveMonthSpan(ctx, yearFilter = null) { const now = new Date(); const end = { year: now.getFullYear(), month: now.getMonth() + 1 }; let start = ctx.hireYm || ctx.earliestYm; if (!start) start = end; if (start && ymKey(start.year, start.month) > ymKey(end.year, end.month)) { start = end; } let months = monthRange(start, end); if (yearFilter != null && Number.isFinite(Number(yearFilter))) { const y = Number(yearFilter); months = months.filter((item) => item.year === y); if (!months.length) { for (let m = 1; m <= 12; m += 1) { if (ymKey(y, m) <= ymKey(end.year, end.month)) months.push({ year: y, month: m }); } } } return months; } /** 该月是否有任一维度的实际导入数据(无数据月不落库、不参与排名) */ function monthHasSourceData(ctx, year, month) { const key = ymKey(year, month); return ctx.kmByMonth.has(key) || ctx.perfByMonth.has(key) || ctx.onlineByMonth.has(key) || ctx.practicalByMonth.has(key) || ctx.faultByMonth.has(key) || ctx.incidentByMonth.has(key); } function computeEmployeeMonth(ctx, year, month) { const key = ymKey(year, month); const km = ctx.kmByMonth.get(key); const perf = ctx.perfByMonth.get(key); const online = ctx.onlineByMonth.get(key); const practical = ctx.practicalByMonth.get(key); const fault = ctx.faultByMonth.get(key); const incident = ctx.incidentByMonth.get(key); const onlineScores = online?.scores || []; const practicalScores = practical?.scores || []; const hasMissingOnline = Boolean(ctx.onlineMissingByMonth?.has(key)); const scores = computeMonthScores({ hasKm: Boolean(km), kmAnomalyCount: km?.anomalyCount || 0, performanceDeduction: perf?.performanceDeduction || 0, drivingDeduction: perf?.drivingDeduction || 0, hasOnline: onlineScores.length > 0, hasMissingOnline, onlineAvg: onlineScores.length ? onlineScores.reduce((a, b) => a + b, 0) / onlineScores.length : 0, hasPractical: practicalScores.length > 0, practicalAvg: practicalScores.length ? practicalScores.reduce((a, b) => a + b, 0) / practicalScores.length : 0, faultTotalCount: fault?.total || 0, faultSuccessCount: fault?.success || 0, incidentOkCount: incident?.ok || 0, incidentIssueCount: incident?.issue || 0, }); return { employeeId: ctx.canonicalId, year: Number(year), month: Number(month), ...scores, }; } async function upsertMonthlyMetrics(metrics) { await db.execute( `INSERT INTO crew_metrics_employee_monthly (year, month, employee_id, km_score, km_anomaly_count, performance_score, performance_deduction, driving_deduction, online_exam, practical_exam, fault_score, fault_success_count, incident_score, incident_ok_count, incident_issue_count, total_score, formula_version, computed_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), 1) ON DUPLICATE KEY UPDATE km_score = VALUES(km_score), km_anomaly_count = VALUES(km_anomaly_count), performance_score = VALUES(performance_score), performance_deduction = VALUES(performance_deduction), driving_deduction = VALUES(driving_deduction), online_exam = VALUES(online_exam), practical_exam = VALUES(practical_exam), fault_score = VALUES(fault_score), fault_success_count = VALUES(fault_success_count), incident_score = VALUES(incident_score), incident_ok_count = VALUES(incident_ok_count), incident_issue_count = VALUES(incident_issue_count), total_score = VALUES(total_score), formula_version = VALUES(formula_version), computed_at = NOW(), version = version + 1`, [ metrics.year, metrics.month, metrics.employeeId, metrics.kmScore, metrics.kmAnomalyCount, metrics.performanceScore, metrics.performanceDeduction, metrics.drivingDeduction, metrics.onlineExam, metrics.practicalExam, metrics.faultScore, metrics.faultSuccessCount, metrics.incidentScore, metrics.incidentOkCount, metrics.incidentIssueCount, metrics.totalScore, FORMULA_VERSION, ], ); } /** * @param {string[]} employeeIds * @param {number[]|null} years 若传入则只重算这些年的月份;否则重算入职→当前全部月 */ async function recomputeEmployeesYears(employeeIds = [], years = []) { await ensureSchema(); const ids = [...new Set((employeeIds || []).map((id) => String(id || '').trim()).filter(Boolean))]; const yearList = [...new Set((years || []).map(Number).filter((y) => Number.isFinite(y)))]; let recomputed = 0; let changed = false; if (!ids.length) { return { recomputed, formulaVersion: FORMULA_VERSION }; } // 组长 / 休假补贴公里保底需要同班组原始公里分,故一并加载队友 const idPlaceholders = ids.map(() => '?').join(','); const seedRows = await db.query( `SELECT employee_id, team, details FROM employees WHERE deleted_at IS NULL AND (employment_status = '在职' OR employment_status IS NULL) AND employee_id IN (${idPlaceholders})`, ids, ); const teamsNeeded = new Set(); for (const row of seedRows || []) { if (usesTeamAvgKm(extractRemarks(row.details)) && row.team) { teamsNeeded.add(String(row.team).trim()); } } const subsidyTeams = await db.query( `SELECT DISTINCT e.team FROM kilometers_records k INNER JOIN employees e ON e.employee_id = k.employee_id AND e.deleted_at IS NULL WHERE k.has_subsidy = 1 AND k.employee_id IN (${idPlaceholders}) AND e.team IS NOT NULL AND TRIM(e.team) != ''`, ids, ).catch(() => []); for (const row of subsidyTeams || []) { if (row.team) teamsNeeded.add(String(row.team).trim()); } const computeIdSet = new Set(ids); if (teamsNeeded.size) { const teamList = [...teamsNeeded]; const teamPlaceholders = teamList.map(() => '?').join(','); const teammates = await db.query( `SELECT employee_id FROM employees WHERE deleted_at IS NULL AND (employment_status = '在职' OR employment_status IS NULL) AND team IN (${teamPlaceholders})`, teamList, ); for (const row of teammates || []) { if (row.employee_id) computeIdSet.add(String(row.employee_id).trim()); } } const upsertKeys = new Set(); for (const id of ids) { for (const variant of idVariants(id)) upsertKeys.add(variant); } /** @type {Array<{ metrics: object, hasKm: boolean, team: string, useTeamAvgKm: boolean, hasSubsidy: boolean, shouldUpsert: boolean }>} */ const pending = []; for (const employeeId of computeIdSet) { const ctx = await loadEmployeeContext(employeeId); const shouldUpsert = ctx.variants.some((variant) => upsertKeys.has(variant)) || upsertKeys.has(ctx.canonicalId); const months = yearList.length ? yearList.flatMap((year) => resolveMonthSpan(ctx, year)) : resolveMonthSpan(ctx, null); const uniqueMonths = new Map(); for (const item of months) uniqueMonths.set(ymKey(item.year, item.month), item); for (const { year, month } of uniqueMonths.values()) { if (!monthHasSourceData(ctx, year, month)) { if (shouldUpsert) { const del = await db.execute( `DELETE FROM crew_metrics_employee_monthly WHERE employee_id = ? AND year = ? AND month = ?`, [ctx.canonicalId, year, month], ); if ((del?.affectedRows || 0) > 0) changed = true; } continue; } const metrics = computeEmployeeMonth(ctx, year, month); const kmBucket = ctx.kmByMonth.get(ymKey(year, month)); pending.push({ metrics, hasKm: Boolean(kmBucket), team: ctx.team || '', useTeamAvgKm: Boolean(ctx.useTeamAvgKm), hasSubsidy: Boolean(kmBucket?.hasSubsidy), shouldUpsert, }); } } // 班组当月「有公里数据」人员的原始公里分均值 const teamMonthScores = new Map(); for (const item of pending) { if (!item.team || !item.hasKm) continue; const key = `${item.team}|${ymKey(item.metrics.year, item.metrics.month)}`; if (!teamMonthScores.has(key)) teamMonthScores.set(key, []); teamMonthScores.get(key).push(Number(item.metrics.kmScore) || 0); } const teamMonthAvg = new Map(); for (const [key, scores] of teamMonthScores.entries()) { if (!scores.length) continue; teamMonthAvg.set(key, round1(scores.reduce((a, b) => a + b, 0) / scores.length)); } for (const item of pending) { // 组长或休假补贴:低于班组均值则保底,高于则按实际 if ((item.useTeamAvgKm || item.hasSubsidy) && item.team) { const avg = teamMonthAvg.get(`${item.team}|${ymKey(item.metrics.year, item.metrics.month)}`); if (avg != null && Number(item.metrics.kmScore) < avg) { item.metrics.kmScore = avg; item.metrics.totalScore = recalcTotalScore(item.metrics); } } if (!item.shouldUpsert) continue; await upsertMonthlyMetrics(item.metrics); recomputed += 1; changed = true; } if (changed) { await sync.bumpVersions(['crew_metrics_employee_monthly']); } return { recomputed, formulaVersion: FORMULA_VERSION }; } /** 兼容旧调用:按人年对列表做重算 */ async function recomputePairs(pairs = []) { const byYear = new Map(); for (const pair of pairs || []) { const employeeId = String(pair.employeeId || '').trim(); const year = Number(pair.year); if (!employeeId || !Number.isFinite(year)) continue; if (!byYear.has(year)) byYear.set(year, new Set()); byYear.get(year).add(employeeId); } let recomputed = 0; for (const [year, idSet] of byYear.entries()) { const report = await recomputeEmployeesYears([...idSet], [year]); recomputed += report.recomputed; } return { recomputed, formulaVersion: FORMULA_VERSION }; } async function recomputeYear(year) { await ensureSchema(); const employees = await db.query( `SELECT employee_id FROM employees WHERE deleted_at IS NULL AND (employment_status = '在职' OR employment_status IS NULL)`, ); return recomputeEmployeesYears( employees.map((row) => row.employee_id), [Number(year)], ); } async function recomputeAll() { await ensureSchema(); const employees = await db.query( `SELECT employee_id FROM employees WHERE deleted_at IS NULL AND (employment_status = '在职' OR employment_status IS NULL)`, ); return recomputeEmployeesYears( employees.map((row) => row.employee_id), [], ); } /** 供前端/调试:单人累计平均(六维与总分) */ function averageMetrics(monthRows = []) { if (!monthRows.length) { return { kmScore: 0, performanceScore: BASE.performance, onlineExam: BASE.online, practicalExam: BASE.practical, faultScore: BASE.fault, incidentScore: BASE.incident, totalScore: round1(0 + BASE.performance + BASE.online + BASE.practical + BASE.fault + BASE.incident), monthCount: 0, }; } const sum = monthRows.reduce((acc, row) => { const kmScore = Number(row.km_score ?? row.kmScore) || 0; const kmAnomaly = Number(row.km_anomaly_count ?? row.kmAnomalyCount) || 0; // 公里维:只平均「有公里数据」的月(无数据月 km=0 且 anomaly=0,不拉低) if (kmScore > 0 || kmAnomaly > 0) { acc.kmScore += kmScore; acc.kmMonths += 1; } acc.performanceScore += Number(row.performance_score ?? row.performanceScore) || 0; acc.onlineExam += Number(row.online_exam ?? row.onlineExam) || 0; acc.practicalExam += Number(row.practical_exam ?? row.practicalExam) || 0; acc.faultScore += Number(row.fault_score ?? row.faultScore) || 0; acc.incidentScore += Number(row.incident_score ?? row.incidentScore) || 0; acc.totalScore += Number(row.total_score ?? row.totalScore) || 0; return acc; }, { kmScore: 0, kmMonths: 0, performanceScore: 0, onlineExam: 0, practicalExam: 0, faultScore: 0, incidentScore: 0, totalScore: 0, }); const n = monthRows.length; const kmScore = sum.kmMonths ? round1(sum.kmScore / sum.kmMonths) : 0; const performanceScore = round1(sum.performanceScore / n); const onlineExam = round1(sum.onlineExam / n); const practicalExam = round1(sum.practicalExam / n); const faultScore = round1(sum.faultScore / n); const incidentScore = round1(sum.incidentScore / n); // 画像分 = 各维累计平均之和(公里维已排除无公里月),避免「仅嘉奖月 km=0」拉低总分 return { kmScore, performanceScore, onlineExam, practicalExam, faultScore, incidentScore, totalScore: round1(kmScore + performanceScore + onlineExam + practicalExam + faultScore + incidentScore), monthCount: n, kmMonthCount: sum.kmMonths, }; } module.exports = { FORMULA_VERSION, BASE, ensureSchema, computeMonthScores, computeEmployeeMonth, averageMetrics, recomputePairs, recomputeEmployeesYears, recomputeYear, recomputeAll, idVariants, normalizeEmployeeId, padEmployeeId, ymKey, parseYm, usesTeamAvgKm, extractRemarks, };