955 lines
31 KiB
JavaScript
955 lines
31 KiB
JavaScript
'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,
|
||
};
|