Agent/server/src/precompute/crew-metrics.js

955 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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-012025-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,
};